diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 583febe8..30bff204 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ +import com.android.build.api.artifact.SingleArtifact import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.ProductFlavor import com.android.build.api.variant.FilterConfiguration @@ -5,6 +6,7 @@ import com.google.protobuf.gradle.id import com.mikepenz.aboutlibraries.plugin.DuplicateMode import com.mikepenz.aboutlibraries.plugin.DuplicateRule import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import java.io.File import java.util.Base64 import java.util.Properties @@ -194,6 +196,7 @@ configure { sourceSets { getByName("main") { kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" + kotlin.directories += "src/wolfskin/java" } } @@ -224,6 +227,47 @@ androidComponents { "Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk" output.outputFileName = outputFileName } + if (variant.name == "defaultDebug") { + val packagedApks = variant.artifacts.get(SingleArtifact.APK) + val artifactLoader = variant.artifacts.getBuiltArtifactsLoader() + val aapt2 = sdkComponents.aapt2.flatMap { it.executable } + val checkWolfskinBranding = tasks.register("checkWolfskinBranding") { + inputs.dir(packagedApks) + inputs.file(aapt2) + doLast { + val artifacts = checkNotNull(artifactLoader.load(packagedApks.get())) { + "Packaged default/debug APK metadata was not found; branding is unverified" + }.elements + check(artifacts.isNotEmpty()) { "No default/debug APKs were found; branding is unverified" } + val header = Regex("""^\s*resource 0x[\da-fA-F]+ (?:[^\s:]+:)?([^\s/]+)/([^\s]+)""") + val visibleTypes = setOf("string", "plurals", "array") + // Upstream legal attribution and its project URL must survive the branding cutover. + val legalNames = setOf("wolfskin_upstream_credit", "wolfskin_upstream_url") + val violations = sortedSetOf() + artifacts.forEach { artifact -> + val dump = providers.exec { + commandLine(aapt2.get().asFile, "dump", "resources", artifact.outputFile) + }.standardOutput.asText.get() + var currentName: String? = null + var inspected = 0 + dump.lineSequence().forEach { line -> + val resource = header.find(line) + if (resource != null) { + currentName = resource.groupValues[2].takeIf { resource.groupValues[1] in visibleTypes } + if (currentName != null) inspected++ + } else if (currentName != null && currentName !in legalNames && line.contains("Wholphin", ignoreCase = true)) { + violations += "${File(artifact.outputFile).name}:$currentName" + } + } + check(inspected > 0) { "No packaged string resources were decoded; branding is unverified" } + logger.lifecycle("Inspected $inspected string/plural/array resources in ${File(artifact.outputFile).name}") + } + check(violations.isEmpty()) { "User-visible upstream branding remains: ${violations.joinToString()}" } + logger.lifecycle("Wolfskin branding check passed (${artifacts.size} packaged debug APKs; legal attribution preserved)") + } + } + tasks.matching { it.name == "assembleDefaultDebug" }.configureEach { dependsOn(checkWolfskinBranding) } + } } } @@ -288,6 +332,7 @@ tasks.named("preBuild") { } dependencies { + implementation(project(":wolfskin-design")) implementation(project(":conductarr-client")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) @@ -407,8 +452,18 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.hilt.android.testing) testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0") + testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.77.0") androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.mockk.android) androidTestImplementation(libs.hilt.android.testing) androidTestImplementation(libs.androidx.compose.ui.test.manifest) } + +tasks.withType().configureEach { + dependsOn(":wolfskin-design:testDebugUnitTest") + systemProperty("robolectric.dependency.dir", project(":wolfskin-design").layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath) + systemProperty("robolectric.offline", "true") + systemProperty("roborazzi.test.record", "true") + systemProperty("wolfskin.captureDirectory", rootProject.layout.buildDirectory.dir("wolfskin-captures").get().asFile.absolutePath) +} diff --git a/app/src/debug/assets/wolfskin-shell.json b/app/src/debug/assets/wolfskin-shell.json new file mode 100644 index 00000000..d99323a9 --- /dev/null +++ b/app/src/debug/assets/wolfskin-shell.json @@ -0,0 +1,78 @@ +{ + "source": "Locked Opus fixture/fixture.json; shell-only projection; debug/test only", + "profiles": [ + { + "key": "justin", + "name": "Justin", + "initial": "J", + "color": "#62c4ff", + "destinations": { + "Home": { + "kind": "home" + }, + "Movies": { + "kind": "library", + "collection": "movies" + }, + "Shows": { + "kind": "library", + "collection": "shows" + }, + "Anime": { + "kind": "library", + "collection": "anime" + }, + "Discover": { + "kind": "discover", + "collection": "discover" + } + } + }, + { + "key": "mindel", + "name": "Mindel", + "initial": "M", + "color": "#ff9d72", + "destinations": { + "Home": { + "kind": "home" + }, + "Movies": { + "kind": "library", + "collection": "movies" + }, + "Shows": { + "kind": "library", + "collection": "shows" + }, + "Discover": { + "kind": "discover", + "collection": "discover" + } + } + }, + { + "key": "shared", + "name": "Shared", + "initial": "S", + "color": "#9be38f", + "destinations": { + "Home": { + "kind": "home" + }, + "Movies": { + "kind": "library", + "collection": "movies" + }, + "Shows": { + "kind": "library", + "collection": "shows" + }, + "Discover": { + "kind": "discover", + "collection": "discover" + } + } + } + ] +} diff --git a/app/src/default/res/mipmap-anydpi-v26/ic_banner.xml b/app/src/default/res/mipmap-anydpi-v26/ic_banner.xml new file mode 100644 index 00000000..ec3f55e3 --- /dev/null +++ b/app/src/default/res/mipmap-anydpi-v26/ic_banner.xml @@ -0,0 +1,4 @@ + + diff --git a/app/src/default/res/mipmap-hdpi/ic_launcher.webp b/app/src/default/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..4ac23262 Binary files /dev/null and b/app/src/default/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/default/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/default/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..de1628cc Binary files /dev/null and b/app/src/default/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/default/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/default/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..df5faba9 Binary files /dev/null and b/app/src/default/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/default/res/mipmap-mdpi/ic_launcher.webp b/app/src/default/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..415cf194 Binary files /dev/null and b/app/src/default/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/default/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/default/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..d9df942a Binary files /dev/null and b/app/src/default/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/default/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/default/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..dea8ff64 Binary files /dev/null and b/app/src/default/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_banner.png b/app/src/default/res/mipmap-xhdpi/ic_banner.png new file mode 100644 index 00000000..ee42b91e Binary files /dev/null and b/app/src/default/res/mipmap-xhdpi/ic_banner.png differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_banner_foreground.png b/app/src/default/res/mipmap-xhdpi/ic_banner_foreground.png new file mode 100644 index 00000000..ee42b91e Binary files /dev/null and b/app/src/default/res/mipmap-xhdpi/ic_banner_foreground.png differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_launcher.webp b/app/src/default/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..924b40eb Binary files /dev/null and b/app/src/default/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/default/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..e218f8bd Binary files /dev/null and b/app/src/default/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/default/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..40674eff Binary files /dev/null and b/app/src/default/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/default/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/default/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..6f5be441 Binary files /dev/null and b/app/src/default/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/default/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/default/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..8c1935c7 Binary files /dev/null and b/app/src/default/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/default/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/default/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..48a4e286 Binary files /dev/null and b/app/src/default/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/default/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/default/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..1cf49fb6 Binary files /dev/null and b/app/src/default/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/default/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/default/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..afc41a8b Binary files /dev/null and b/app/src/default/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/default/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/default/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..122a33e0 Binary files /dev/null and b/app/src/default/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/default/res/values-hu/strings.xml b/app/src/default/res/values-hu/strings.xml new file mode 100644 index 00000000..fc653df6 --- /dev/null +++ b/app/src/default/res/values-hu/strings.xml @@ -0,0 +1,6 @@ + + + Wolfskin alapértelmezett beállítása + Wolfskin kompakt elrendezés + A Wolfskin frissült a %s verzióra + diff --git a/app/src/default/res/values-pl/strings.xml b/app/src/default/res/values-pl/strings.xml new file mode 100644 index 00000000..e334b3ba --- /dev/null +++ b/app/src/default/res/values-pl/strings.xml @@ -0,0 +1,4 @@ + + + O Wolfskin + diff --git a/app/src/default/res/values/strings.xml b/app/src/default/res/values/strings.xml new file mode 100644 index 00000000..dd5ba542 --- /dev/null +++ b/app/src/default/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Wolfskin + Wolfskin + Wolfskin Default + Wolfskin Compact + Wolfskin updated to %s + diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index a1410d0b..ec61583d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -66,7 +66,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.ExceptionHandler @@ -234,7 +233,7 @@ class MainActivity : AppCompatActivity() { viewModel.appStart(intent) setContent { - MaterialTheme(colorScheme = WolfskinThemeColors.darkScheme) { + MaterialTheme { Surface(Modifier.fillMaxSize()) { val userPreferences by userPreferencesService.flow.collectAsState(null) if (userPreferences == null) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index 26dda908..fcaa3165 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -34,7 +34,7 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.ui.components.AppScreensaver -import com.github.damontecres.wholphin.ui.nav.ApplicationContent +import org.justinvisser.wolfskin.tv.WolfskinApp import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.util.InterfaceCustomization @@ -111,7 +111,7 @@ fun MainContent( } if (showContent) { - ApplicationContent( + WolfskinApp( user = current.user, server = current.server, navigationManager = navigationManager, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 63761d99..b6d9426c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -557,7 +557,7 @@ sealed interface AppPreference { val ThemeColors = AppChoicePreference( title = R.string.app_theme, - defaultValue = AppThemeColors.WOLFSKIN, + defaultValue = AppThemeColors.PURPLE, getter = { it.interfacePreferences.appThemeColors }, setter = { prefs, value -> prefs.updateInterfacePreferences { appThemeColors = value } @@ -647,6 +647,19 @@ sealed interface AppPreference { destination = Destination.Settings(PreferenceScreenOption.ADVANCED), ) + val WolfskinScreens = + AppSwitchPreference( + title = R.string.wolfskin_screens, + defaultValue = true, + getter = { org.justinvisser.wolfskin.tv.WolfskinScreenPreference.enabled }, + setter = { prefs, value -> + org.justinvisser.wolfskin.tv.WolfskinScreenPreference.enabled = value + prefs + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + val UserProfileSettings = AppDestinationPreference( title = R.string.override_user_profile_settings, @@ -1306,6 +1319,7 @@ val advancedPreferences = title = R.string.ui_interface, preferences = listOf( + AppPreference.WolfskinScreens, AppPreference.ShowClock, AppPreference.BackdropStylePref, AppPreference.ShowLogos, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt index 17649be9..ce8952a7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt @@ -41,7 +41,6 @@ fun WatchedIconBackground(): Color = when (LocalTheme.current) { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, - AppThemeColors.WOLFSKIN, AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, @@ -56,8 +55,6 @@ fun WatchedIconBackground(): Color = @Composable fun WatchedIconColor(): Color = when (LocalTheme.current) { - AppThemeColors.WOLFSKIN -> MaterialTheme.colorScheme.onPrimary - AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, AppThemeColors.BLUE, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt index f2addcd8..9021e213 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt @@ -143,7 +143,6 @@ fun sliderActiveColor(focused: Boolean): Color { return when (theme) { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, - AppThemeColors.WOLFSKIN, AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, @@ -180,7 +179,6 @@ fun sliderInactiveColor(focused: Boolean): Color { return when (theme) { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, - AppThemeColors.WOLFSKIN, -> { MaterialTheme.colorScheme.border .copy(alpha = .25f) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 110cb637..5bef2675 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -35,6 +35,9 @@ import java.util.UUID sealed class Destination( val fullScreen: Boolean = false, ) : NavKey { + @Serializable + data class Wolfskin(val route: String) : Destination(false) + @Serializable data class Home( val id: Long = 0L, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 63789d6c..3e449875 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -69,6 +69,10 @@ fun DestinationContent( LaunchedEffect(Unit) { onClearBackdrop.invoke() } } when (destination) { + is Destination.Wolfskin -> { + org.justinvisser.wolfskin.tv.WolfskinDestinationContent(destination.route, modifier) + } + is Destination.Home -> { HomePage( preferences = preferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index c26cb891..100b6caf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -771,7 +771,6 @@ fun navItemColor( when (theme) { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, - AppThemeColors.WOLFSKIN, AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SwitchPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SwitchPreference.kt index 43e213ea..daf6fa07 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SwitchPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SwitchPreference.kt @@ -70,7 +70,6 @@ fun SwitchColors(): SwitchColors { return when (theme) { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, - AppThemeColors.WOLFSKIN, AppThemeColors.BLUE, -> { SwitchDefaults.colors() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt index 8cce37e5..8ae95b25 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt @@ -13,10 +13,9 @@ import com.github.damontecres.wholphin.ui.theme.colors.OledThemeColors import com.github.damontecres.wholphin.ui.theme.colors.OrangeThemeColors import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors import com.github.damontecres.wholphin.ui.theme.colors.RedThemeColors -import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors val LocalTheme = - compositionLocalOf { AppThemeColors.WOLFSKIN } + compositionLocalOf { AppThemeColors.PURPLE } fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors = when (appThemeColors) { @@ -28,14 +27,13 @@ fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors = AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors AppThemeColors.RED -> RedThemeColors AppThemeColors.BROWN -> BrownThemeColors - AppThemeColors.WOLFSKIN -> WolfskinThemeColors - AppThemeColors.UNRECOGNIZED -> WolfskinThemeColors + AppThemeColors.UNRECOGNIZED -> PurpleThemeColors } @Composable fun WholphinTheme( darkTheme: Boolean = true, - appThemeColors: AppThemeColors = AppThemeColors.WOLFSKIN, + appThemeColors: AppThemeColors = AppThemeColors.PURPLE, content: @Composable () -> Unit, ) { val themeColors = getThemeColors(appThemeColors) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/WolfskinThemeColors.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/WolfskinThemeColors.kt deleted file mode 100644 index ee0a77b5..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/WolfskinThemeColors.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.github.damontecres.wholphin.ui.theme.colors - -import androidx.compose.ui.graphics.Color -import com.github.damontecres.wholphin.ui.theme.ThemeColors - -/** Lilac accents on near-black surfaces, with upstream Purple available separately. */ -val WolfskinThemeColors = - object : ThemeColors { - private val background = Color(0xFF07070A) - private val lilac = Color(0xFFD2BCFF) - private val onLilac = Color(0xFF241334) - private val container = Color(0xFF382A49) - private val onContainer = Color(0xFFEADCFF) - private val surfaceVariant = Color(0xFF24202D) - - override val lightSchemeMaterial = PurpleThemeColors.lightSchemeMaterial - override val lightScheme = PurpleThemeColors.lightScheme - - override val darkSchemeMaterial = - PurpleThemeColors.darkSchemeMaterial.copy( - primary = lilac, - onPrimary = onLilac, - primaryContainer = container, - onPrimaryContainer = onContainer, - secondary = lilac, - onSecondary = onLilac, - secondaryContainer = container, - onSecondaryContainer = onContainer, - tertiary = lilac, - onTertiary = onLilac, - tertiaryContainer = container, - onTertiaryContainer = onContainer, - background = background, - surface = background, - surfaceVariant = surfaceVariant, - inversePrimary = Color(0xFF705090), - ) - - override val darkScheme = - PurpleThemeColors.darkScheme.copy( - primary = lilac, - onPrimary = onLilac, - primaryContainer = container, - onPrimaryContainer = onContainer, - secondary = lilac, - onSecondary = onLilac, - secondaryContainer = container, - onSecondaryContainer = onContainer, - tertiary = lilac, - onTertiary = onLilac, - tertiaryContainer = container, - onTertiaryContainer = onContainer, - background = background, - surface = background, - surfaceVariant = surfaceVariant, - inversePrimary = Color(0xFF705090), - border = lilac.copy(alpha = .75f), - ) - } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 55553166..910853e9 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -128,7 +128,7 @@ enum AppThemeColors { OLED_BLACK = 5; RED = 6; BROWN = 7; - WOLFSKIN = 8; + reserved 8; } enum EdgeStyle{ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml b/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml index ec3f55e3..4c5f707b 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml @@ -1,4 +1,9 @@ - + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp index 4ac23262..5b7669dc 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp index de1628cc..8e05a03a 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index df5faba9..e6a5972a 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 415cf194..f44d1093 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp index d9df942a..9e9214e8 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index dea8ff64..3766935e 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_banner.png b/app/src/main/res/mipmap-xhdpi/ic_banner.png index ee42b91e..4fa742db 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_banner.png and b/app/src/main/res/mipmap-xhdpi/ic_banner.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_banner_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_banner_foreground.png index ee42b91e..52b72c47 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_banner_foreground.png and b/app/src/main/res/mipmap-xhdpi/ic_banner_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index 924b40eb..ce75a196 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp index e218f8bd..22298ad1 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 40674eff..3f7c7e29 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 6f5be441..e5cef34d 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp index 8c1935c7..fafcb4f1 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 48a4e286..5c1a8e36 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index 1cf49fb6..90341396 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp index afc41a8b..dbe5821e 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 122a33e0..2ef5c7d0 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3783bfdf..1fadeac7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ - Wolfskin - Wolfskin + Wholphin + Wholphin About Active Recordings @@ -604,8 +604,8 @@ Fill Fill width Fill height - Wolfskin Default - Wolfskin Compact + Wholphin Default + Wholphin Compact Series Thumb images Episode Thumbnail images @@ -637,7 +637,6 @@ @string/black @string/red @string/brown - @string/wolfskin_theme Ignore @@ -831,7 +830,7 @@ Select all Separate types Prefer showing logos for titles - Wolfskin updated to %s + Wholphin updated to %s Remove from continue watching Remove series from next up View series removed from next up diff --git a/app/src/main/res/values/wolfskin.xml b/app/src/main/res/values/wolfskin.xml index 2aca4d36..0308ae9e 100644 --- a/app/src/main/res/values/wolfskin.xml +++ b/app/src/main/res/values/wolfskin.xml @@ -1,6 +1,6 @@ - Wolfskin + Wolfskin screens based on Wholphin by damontecres https://github.com/damontecres/Wholphin diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/WolfskinThemeColorsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/WolfskinThemeColorsTest.kt index 3ef4ad20..e262e2ad 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/WolfskinThemeColorsTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/WolfskinThemeColorsTest.kt @@ -2,7 +2,7 @@ package com.github.damontecres.wholphin.ui import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance -import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors +import org.justinvisser.wolfskin.design.WolfskinColors import org.junit.Assert.assertTrue import org.junit.Test @@ -10,8 +10,8 @@ import org.junit.Test class WolfskinThemeColorsTest { @Test fun darkPaletteHasReadableNormalAndFocusedText() { - val television = WolfskinThemeColors.darkScheme - val material = WolfskinThemeColors.darkSchemeMaterial + val television = WolfskinColors.darkScheme + val material = WolfskinColors.darkSchemeMaterial val textAndBackgrounds = listOf( television.onBackground to television.background, diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/SettingsHandOffCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/SettingsHandOffCaptureTest.kt new file mode 100644 index 00000000..dd5ee44c --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/SettingsHandOffCaptureTest.kt @@ -0,0 +1,76 @@ +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.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.SeerrRatingsService +import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.main.settings.HomePageSettingsState +import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage +import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsViewModel +import com.github.damontecres.wholphin.util.LoadingState +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.every +import io.mockk.mockk +import java.io.File +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.WolfskinTheme +import org.justinvisser.wolfskin.design.WolfskinTokens +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** The frozen st-094 state opens User profile settings, not the separate Playback press path. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SettingsHandOffCaptureTest { + @get:Rule val rule = createAndroidComposeRule() + + @Before fun fullscreen() { + 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 + } + + @Test fun st094_realUserProfileHomeSettingsHandOff() { + val model = mockk(relaxed = true) + every { model.state } returns MutableStateFlow(HomePageSettingsState.EMPTY.copy(loading = LoadingState.Success)) + every { model.serverPluginActive } returns MutableStateFlow(false) + every { model.settingsChanged } returns MutableStateFlow(false) + val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) + val ratings = SeerrRatingsService(mockk(relaxed = true), mockk(relaxed = true)) + val images = mockk(relaxed = true) + rule.setContent { + WolfskinTheme { + CompositionLocalProvider(LocalSeerrRatingsService provides ratings, LocalImageUrlService provides images) { + HomeSettingsPage(preferences, Modifier.fillMaxSize().background(WolfskinTokens.Color.bg), viewModel = model) + } + } + } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + rule.onNodeWithText(rule.activity.getString(R.string.customize_home)).assertIsDisplayed() + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))) + directory.mkdirs() + rule.onRoot().captureRoboImage(File(directory, "st-094.png").absolutePath) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellRoutingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellRoutingTest.kt new file mode 100644 index 00000000..d1944584 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellRoutingTest.kt @@ -0,0 +1,57 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption +import java.util.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** The actual Advanced preference persists its value and changes the shell's renderer policy. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class ShellRoutingTest { + private val context: Application get() = ApplicationProvider.getApplicationContext() + private val movies = Destination.MediaItem(UUID.fromString("00000000-0000-0000-0000-000000000001"), BaseItemKind.COLLECTION_FOLDER, CollectionType.MOVIES) + private val libraries = mapOf("movies" to movies, "shows" to movies, "anime" to movies) + + @Before fun initialize() { WolfskinScreenPreference.initialize(context); WolfskinScreenPreference.enabled = true } + @After fun restore() { WolfskinScreenPreference.enabled = true } + + @Test fun advancedSwitchRestoresUpstreamHomeAndLibrariesAndPersistsAcrossReload() { + val preference = AppPreference.WolfskinScreens + val preferences = AppPreferences.getDefaultInstance() + assertNull(resolveUpstreamDestination(Destination.Home(), true, libraries)) + assertNull(resolveUpstreamDestination(Destination.Wolfskin("movies"), true, libraries)) + preference.setter(preferences, false) + WolfskinScreenPreference.initialize(context) + assertFalse(preference.getter(preferences)) + assertTrue(resolveUpstreamDestination(Destination.Home(), WolfskinScreenPreference.enabled, libraries) is Destination.Home) + listOf("movies", "shows", "anime").forEach { route -> + assertEquals(movies, resolveUpstreamDestination(Destination.Wolfskin(route), WolfskinScreenPreference.enabled, libraries)) + } + preference.setter(preferences, true) + assertNull(resolveUpstreamDestination(Destination.Home(), WolfskinScreenPreference.enabled, libraries)) + assertNull(resolveUpstreamDestination(Destination.Wolfskin("movies"), WolfskinScreenPreference.enabled, libraries)) + } + + @Test fun settingsAndPickerNeverBecomeUpstreamScreensButUnrelatedRoutesPassThrough() { + val settings = Destination.Wolfskin("settings") + val upstream = Destination.Settings(PreferenceScreenOption.ADVANCED) + for (enabled in listOf(false, true)) { + assertNull(resolveUpstreamDestination(settings, enabled, libraries)) + assertEquals(upstream, resolveUpstreamDestination(upstream, enabled, libraries)) + assertEquals(Destination.Search("name"), resolveUpstreamDestination(Destination.Search("name"), enabled, libraries)) + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt new file mode 100644 index 00000000..78690396 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt @@ -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() + + @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(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) + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt new file mode 100644 index 00000000..2b7562df --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt @@ -0,0 +1,23 @@ +package org.justinvisser.wolfskin.tv + +import android.content.Context +import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.data.model.JellyfinUser +import org.json.JSONObject +import org.justinvisser.wolfskin.design.ShellPerson + +/** Prototype identities are packaged exclusively in debug assets, never release resources. */ +fun readShellPeople(context: Context, saved: List): List { + if (BuildConfig.DEBUG) { + val json = context.assets.open("wolfskin-shell.json").bufferedReader().use { JSONObject(it.readText()) } + val profiles = json.getJSONArray("profiles") + return List(profiles.length()) { index -> + val person = profiles.getJSONObject(index) + ShellPerson(person.getString("key"), person.getString("name"), person.getString("initial"), person.getJSONObject("destinations").has("Anime")) + } + } + return saved.map { user -> + val name = user.name.orEmpty() + ShellPerson(name.lowercase(), name, name.take(1).uppercase(), false) + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRouting.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRouting.kt new file mode 100644 index 00000000..e69d1496 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRouting.kt @@ -0,0 +1,13 @@ +package org.justinvisser.wolfskin.tv + +import com.github.damontecres.wholphin.ui.nav.Destination + +/** The rollout switch changes only intercepted screens; unrelated destinations always pass through. */ +fun resolveUpstreamDestination(destination: Destination, wolfskinEnabled: Boolean, libraries: Map): Destination? = + when (destination) { + is Destination.Home, is Destination.MediaItem -> if (wolfskinEnabled) null else destination + is Destination.Wolfskin -> if (!wolfskinEnabled && destination.route in LIBRARY_ROUTES) libraries[destination.route] else null + else -> destination + } + +private val LIBRARY_ROUTES = setOf("movies", "shows", "anime") diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt new file mode 100644 index 00000000..068c029b --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -0,0 +1,207 @@ +package org.justinvisser.wolfskin.tv + +import android.content.Context +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +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.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +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.DestinationContent +import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.justinvisser.wolfskin.design.PeopleStatus +import org.justinvisser.wolfskin.design.SettingsList +import org.justinvisser.wolfskin.design.ShellAction +import org.justinvisser.wolfskin.design.ShellPerson +import org.justinvisser.wolfskin.design.ShellPlaceholder +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.ShellSurface +import org.justinvisser.wolfskin.design.WolfskinTheme +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade + +/** View memory must outlive the upstream setup entry replaced by a user switch. */ +@Singleton +class WolfskinSession @Inject constructor() { + var state by mutableStateOf(null) + var serverId: String? = null + var onAction: (ShellAction) -> Unit = {} +} + +/** The shell routes only through the existing NavigationManager and real settings pages. */ +@HiltViewModel +class WolfskinShellViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, + val session: WolfskinSession, + private val repository: ServerRepository, + private val navigation: NavigationManager, + private val setupNavigation: SetupNavigationManager, + private val backdrop: BackdropService, + private val libraries: LibraryReadFacade, +) : ViewModel() { + val backdropFlow: StateFlow = backdrop.backdropFlow + var libraryDestinations by mutableStateOf>(emptyMap()) + private set + var signedInPerson by mutableStateOf("") + private set + + suspend fun load(server: JellyfinServer, user: JellyfinUser) { + WolfskinScreenPreference.initialize(context) + signedInPerson = user.name.orEmpty().lowercase() + if (session.state == null || session.serverId != server.id.toString()) { + val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() } + val people = readShellPeople(context, saved.ifEmpty { listOf(user) }) + session.serverId = server.id.toString() + session.state = ShellState(people, signedInPerson) { session.onAction(it) } + } + session.onAction = { action -> handleAction(action, server, user) } + try { + libraryDestinations = libraries.readDestinations(user.id) + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + libraryDestinations = emptyMap() + } + } + + fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } } + + private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) { + when (action) { + is ShellAction.Destination -> if (action.route == "settings") navigation.navigateTo(Destination.Wolfskin("settings")) else navigation.replace(if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)) + ShellAction.Pop -> navigation.goBack() + is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) + is ShellAction.Person -> selectPerson(action.person, server, user) + ShellAction.RetryPeople -> viewModelScope.launch { load(server, user) } + } + } + + private fun selectPerson(person: ShellPerson, server: JellyfinServer, user: JellyfinUser) { + navigation.replace(Destination.Home()) + if (person.key == user.name.orEmpty().lowercase()) return + viewModelScope.launch { + val saved = withContext(Dispatchers.IO) { + repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.name.equals(person.name, ignoreCase = true) } + } + if (saved == null || saved.isProtected) { + signedInPerson = "" + return@launch + } + try { + val current = repository.changeUser(server, saved) + setupNavigation.navigateTo(SetupDestination.AppContent(current)) + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + signedInPerson = "" + session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true } + } + } + } +} + +/** Settings groups open the fork's own pages; the prototype hand-off panel is never shipped. */ +fun handOffDestination(group: String): Destination = when (group) { + "advanced" -> Destination.Settings(PreferenceScreenOption.ADVANCED) + "profile" -> Destination.HomeSettings + else -> Destination.Settings(PreferenceScreenOption.BASIC) +} + +private val LocalShellState = compositionLocalOf { error("Wolfskin destination requires the shell") } + +/** The one upstream hook preserves destination decorators and full-screen hand-offs. */ +@Composable +fun WolfskinApp( + server: JellyfinServer, + user: JellyfinUser, + navigationManager: NavigationManager, + preferences: UserPreferences, + modifier: Modifier = Modifier, + viewModel: WolfskinShellViewModel = hiltViewModel(), +) { + LaunchedEffect(server.id, user.id) { viewModel.load(server, user) } + val loading = remember(user.id) { + ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading } + } + val state = viewModel.session.state ?: loading + WolfskinTheme { + CompositionLocalProvider(LocalShellState provides state) { + Box(modifier) { + val current = navigationManager.backStack.last() + if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) { + val backdrop by viewModel.backdropFlow.collectAsStateWithLifecycle() + Backdrop( + backdrop = backdrop, + drawerIsOpen = false, + backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle, + ) + } + NavDisplay( + backStack = navigationManager.backStack, + onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }, + entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), + entryProvider = { destination -> + NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") { + val route = when (destination) { + is Destination.Home -> "home" + is Destination.Wolfskin -> destination.route + else -> "pushed" + } + LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) } + 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) + } + } + } + } + }, + ) + } + } + } +} + +/** The single registered destination branch delegates Wolfskin-only screens back to the shell. */ +@Composable +fun WolfskinDestinationContent(route: String, modifier: Modifier = Modifier) { + if (route == "settings") SettingsList(LocalShellState.current, modifier) else ShellPlaceholder(route, modifier) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinScreenPreference.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinScreenPreference.kt new file mode 100644 index 00000000..b85b702f --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinScreenPreference.kt @@ -0,0 +1,27 @@ +package org.justinvisser.wolfskin.tv + +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.edit +import androidx.preference.PreferenceManager + +/** The single rollout preference is exposed only in upstream Advanced settings. */ +object WolfskinScreenPreference { + private const val KEY = "wolfskin.screens.enabled" + private lateinit var preferences: SharedPreferences + private var current by mutableStateOf(true) + + fun initialize(context: Context) { + preferences = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) + current = preferences.getBoolean(KEY, true) + } + var enabled: Boolean + get() = current + set(value) { + current = value + preferences.edit { putBoolean(KEY, value) } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt new file mode 100644 index 00000000..50df9f12 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt @@ -0,0 +1,28 @@ +package org.justinvisser.wolfskin.tv.read + +import com.github.damontecres.wholphin.ui.nav.Destination +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import java.util.UUID +import javax.inject.Inject + +/** SDK reads are confined here so shell and screen state never depend on SDK responses. */ +class LibraryReadFacade @Inject constructor(private val api: ApiClient) { + suspend fun readDestinations(userId: UUID): Map { + val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty() + return buildMap { + libraries.forEach { library -> + val name = library.name.orEmpty().lowercase() + val key = when { + "anime" in name -> "anime" + library.collectionType == CollectionType.MOVIES -> "movies" + library.collectionType == CollectionType.TVSHOWS -> "shows" + else -> null + } + if (key != null) put(key, Destination.MediaItem(library.id, BaseItemKind.COLLECTION_FOLDER, library.collectionType)) + } + } + } +} diff --git a/docker/README.md b/docker/README.md index 394aa9cd..0ccf48aa 100644 --- a/docker/README.md +++ b/docker/README.md @@ -7,7 +7,7 @@ Android SDK. ```sh git fetch origin --tags make apk # clean, signed default release: three ABI splits + universal -make test # upstream testDefaultDebugUnitTest in the same image, no secrets +make test # app default/debug suite plus wolfskin-design JVM/capture suite make apk-debug # default debug, no secrets; MPV stub and no extra codec decoders ``` @@ -48,6 +48,78 @@ not the working tree. An uncommitted merge still builds with the old version nam Neither an upstream merge nor its validation build authorizes a release or TV installation. +## Wolfskin shell + +Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in +entry in `MainContent.kt` calls `WolfskinApp`; Wolfskin-only routes use the single +`Destination.Wolfskin` subtype in the existing navigation stack. Other destinations +continue through upstream `DestinationContent`. Full-screen player/settings pages +cover the Wolfskin nav. + +The independent `:wolfskin-design` module owns the shell components, fixed person +colours, focus policy, per-person root-view memory, palette and motion. Its +`tokens.json` is the copied approved TV-spec source, retaining the locked prototype +SHA and provenance. `shell-tokens.json` adds shell measurements from that same +prototype. Generate the committed Kotlin, CSS and checksum manifest together: + +```sh +python3 wolfskin-design/generate_tokens.py +python3 wolfskin-design/generate_tokens.py --check +``` + +`preBuild` checks the manifest against the source JSON, generator and generated +outputs. Editing any of them without regenerating fails the build. The shell uses +the generated palette rather than the retired `WOLFSKIN` theme-enum value; the old +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. + +Settings pills have a separate 1 dp stroke (2 px on the stage), not a focus ring. +The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box +padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width +ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%. + +The small gear under the final person opens Settings with Requests focused. +Settings groups hand off to existing upstream pages; the Requests entry explicitly +has no request data yet. **Advanced settings → Wolfskin screens** is the persistent +rollout switch: turning it off renders upstream Home and library destinations with +the upstream Backdrop and top scrim, while retaining Wolfskin's nav, picker and +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 +prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources +do not package it. Saved Jellyfin identities use existing upstream services. The +shell makes no Conductarr calls and does not add Conductarr sign-in. + +Native Roborazzi captures use Robolectric SDK 34 and +`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and +the sampled Compose nav-spring curve to `build/wolfskin-captures/`. Key replays +exercise the Android event adapter, including the 6/8/10/11-press shell paths. +The profile hand-off capture targets the real `HomeSettingsPage`; Playback is a +separate press-count path. + +Robolectric SDK 28/30/34 runtimes resolve through Gradle's existing persistent +dependency cache, then copy to `wolfskin-design/build/robolectric-sdk/`. Test workers +use that local directory in offline mode instead of downloading Android JARs into +the container's ephemeral home. A cache miss still needs Maven Central; rate-limited +hosts can seed the existing cache from SHA512-verified local Maven artifacts without +changing the wrapper or adding volumes. + +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 +artifact provider and AAPT2. It checks all string, plural and array configurations, +rejecting user-visible upstream branding while preserving the upstream legal +attribution and project URL. The check does not depend on incremental merge files. + ## Toolchain and disk use `Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools diff --git a/settings.gradle.kts b/settings.gradle.kts index f2687b17..76b787aa 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,4 +30,5 @@ dependencyResolutionManagement { rootProject.name = "Wholphin" include(":app") include(":wholphin-mpv-stub") +include(":wolfskin-design") include(":conductarr-client") diff --git a/wolfskin-design/build.gradle.kts b/wolfskin-design/build.gradle.kts new file mode 100644 index 00000000..96fe9988 --- /dev/null +++ b/wolfskin-design/build.gradle.kts @@ -0,0 +1,67 @@ +import java.security.MessageDigest +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "org.justinvisser.wolfskin.design" + compileSdk = libs.versions.compileSdk.get().toInt() + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } + buildFeatures { compose = true } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + testOptions.unitTests.isIncludeAndroidResources = true +} +kotlin.compilerOptions.jvmTarget = JvmTarget.JVM_11 + +val robolectricSdk by configurations.creating { + isCanBeResolved = true + isCanBeConsumed = false + isTransitive = false +} +val cacheRobolectricSdk by tasks.registering(Copy::class) { + from(robolectricSdk) + into(layout.buildDirectory.dir("robolectric-sdk")) +} + +val checkTokenDrift by tasks.registering { + inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") }) + doLast { + file("tokens.sha256").readLines().forEach { line -> + val (expected, name) = line.split(" ", limit = 2) + val actual = MessageDigest.getInstance("SHA-256").digest(file(name).readBytes()).joinToString("") { "%02x".format(it) } + check(actual == expected) { "Token drift in $name: run python3 wolfskin-design/generate_tokens.py" } + } + } +} +tasks.named("preBuild") { dependsOn(checkTokenDrift) } +tasks.withType().configureEach { + dependsOn(cacheRobolectricSdk) + systemProperty("robolectric.dependency.dir", layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath) + systemProperty("robolectric.offline", "true") + systemProperty("robolectric.graphicsMode", "NATIVE") + systemProperty("roborazzi.test.record", "true") + systemProperty("wolfskin.captureDirectory", rootProject.layout.buildDirectory.dir("wolfskin-captures").get().asFile.absolutePath) +} + +dependencies { + robolectricSdk("org.robolectric:android-all-instrumented:9-robolectric-4913185-2-i7") + robolectricSdk("org.robolectric:android-all-instrumented:11-robolectric-6757853-i7") + robolectricSdk("org.robolectric:android-all-instrumented:14-robolectric-10818077-i7") + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.tv.material) + implementation(libs.coil.compose) + debugImplementation(libs.androidx.compose.ui.test.manifest) + testImplementation(libs.junit) + testImplementation(libs.robolectric) + implementation(libs.androidx.compose.material3) + testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0") + testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.77.0") +} diff --git a/wolfskin-design/generate_tokens.py b/wolfskin-design/generate_tokens.py new file mode 100755 index 00000000..63a9707e --- /dev/null +++ b/wolfskin-design/generate_tokens.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Generate Compose and CSS tokens and a build-verifiable drift manifest.""" +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +KOTLIN = Path("src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt") + + +def number(value: float) -> str: + """Render stable Kotlin decimal literals.""" + return str(float(value)) + + +def property_name(name: str) -> str: + """Keep source names valid as Kotlin identifiers.""" + 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]: + """Translate locked values into platform-resolvable Compose and CSS tokens.""" + lines = [ + "// Generated by generate_tokens.py; edit the JSON sources instead.", + "package org.justinvisser.wolfskin.design", + "", + "import androidx.compose.ui.graphics.Color as ComposeColor", + "import androidx.compose.ui.text.TextStyle", + "import androidx.compose.ui.text.font.FontWeight", + "import androidx.compose.ui.unit.dp", + "import androidx.compose.ui.unit.em", + "import androidx.compose.ui.unit.sp", + "", + "/** The locked TV design, converted at the source density. */", + "object WolfskinTokens {", + ] + css = ["/* Generated from the locked TV token sources. */", ":root {"] + for group_name, group in tokens.items(): + if group_name.startswith("$") or group_name in ("meta", "font"): + continue + lines.append(f" object {group_name[0].upper() + group_name[1:]} {{") + for name, token in group.items(): + if not isinstance(token, dict): + continue + name = property_name(name) + kind = token.get("$type") + value = token.get("$value") + if kind == "color": + rgba = value.lstrip("#") + argb = (rgba[6:8] if len(rgba) == 8 else "ff") + rgba[:6] + lines.append(f" val {name} = ComposeColor(0x{argb.upper()})") + css.append(f" --{group_name}-{name}: {value};") + elif kind == "typography" and "sp" in token: + size = token["sp"] + weight = token.get("weight", token["$value"]["fontWeight"]) + height = size * token.get("lineHeight", 1.3) + tracking = token.get("letterSpacingEm", 0) + 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;") + elif kind == "typography" and "sizes" in token: + for context, pixels in token["sizes"].items(): + lines.append(f" val {name}{context[0].upper() + context[1:]} = TextStyle(fontSize = {number(pixels / 2)}.sp, fontWeight = {compose_weight(token['weight'])})") + elif kind == "duration": + lines.append(f" const val {name} = {int(value.removesuffix('ms'))}") + css.append(f" --{group_name}-{name}: {value};") + elif kind == "transition" and value.get("type") == "spring": + lines.append(f" const val {name}DampingRatio = {number(value['dampingRatio'])}f") + lines.append(f" const val {name}Stiffness = {number(value['stiffness'])}f") + elif kind == "shadow": + for suffix in ("gapPx", "ringPx"): + lines.append(f" val {name}{suffix[0].upper() + suffix[1:-2]} = {number(token[suffix] / 2)}.dp") + elif isinstance(token.get("dp"), (float, int)): + lines.append(f" val {name} = {number(token['dp'])}.dp") + css.append(f" --{group_name}-{name}: {number(token['px'])}px;") + elif isinstance(token.get("dp"), list): + for suffix, dimension in zip(("Width", "Height"), token["dp"]): + lines.append(f" val {name}{suffix} = {number(dimension)}.dp") + elif isinstance(token.get("dp"), dict): + for suffix, dimension in token["dp"].items(): + lines.append(f" val {name}{suffix[0].upper() + suffix[1:]} = {number(dimension)}.dp") + elif isinstance(value, (float, int)) and not isinstance(value, bool): + lines.append(f" const val {name} = {number(value)}f") + elif kind in ("gradient", "filter", "rule") and isinstance(value, str): + lines.append(f" const val {name} = {json.dumps(value)}") + if kind == "gradient": + css.append(f" --{group_name}-{name}: {value};") + lines.append(" }") + lines.append("}") + css.append("}") + return "\n".join(lines) + "\n", "\n".join(css) + "\n" + + +def main() -> None: + """Write generated artifacts, or fail when committed artifacts drift.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + arguments = parser.parse_args() + tokens = json.loads((ROOT / "tokens.json").read_text()) + tokens.update(json.loads((ROOT / "shell-tokens.json").read_text())) + kotlin, css = render_tokens(tokens) + outputs = {str(KOTLIN): kotlin, "tokens.css": css} + inputs = ["tokens.json", "shell-tokens.json", "generate_tokens.py"] + checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs} + checksums.update({name: hashlib.sha256(content.encode()).hexdigest() for name, content in outputs.items()}) + outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items())) + for name, content in outputs.items(): + path = ROOT / name + if arguments.check: + if not path.exists() or path.read_text() != content: + raise SystemExit(f"Token drift: run python3 wolfskin-design/generate_tokens.py ({name})") + else: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + print("Token drift check passed" if arguments.check else "Generated Kotlin, CSS and drift manifest") + + +if __name__ == "__main__": + main() diff --git a/wolfskin-design/shell-tokens.json b/wolfskin-design/shell-tokens.json new file mode 100644 index 00000000..b24e8e97 --- /dev/null +++ b/wolfskin-design/shell-tokens.json @@ -0,0 +1,320 @@ +{ + "shell": { + "navLabelFade": { + "$type": "duration", + "$value": "200ms", + "source": "index.html:81; nav label color transition uses 200 ms ease-out" + }, + "zero": { + "$type": "dimension", + "px": 0, + "dp": 0.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "stageWidth": { + "$type": "dimension", + "px": 1920, + "dp": 960.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "stageHeight": { + "$type": "dimension", + "px": 1080, + "dp": 540.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "wordmarkWidth": { + "$type": "dimension", + "px": 169, + "dp": 84.5, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "wordmarkTop": { + "$type": "dimension", + "px": 73.6, + "dp": 36.8, + "source": "index.html:74,79: centre a 46.8 px line in the 64 px nav item" + }, + "navHomeWidth": { + "$type": "dimension", + "px": 149, + "dp": 74.5, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "navDiscoverWidth": { + "$type": "dimension", + "px": 198, + "dp": 99.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "navMoviesWidth": { + "$type": "dimension", + "px": 171, + "dp": 85.5, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "navShowsWidth": { + "$type": "dimension", + "px": 161, + "dp": 80.5, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "navAnimeWidth": { + "$type": "dimension", + "px": 158, + "dp": 79.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "navItemTop": { + "$type": "dimension", + "px": 65, + "dp": 32.5, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerTitleTop": { + "$type": "dimension", + "px": 272.3, + "dp": 136.15, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerPeopleTop": { + "$type": "dimension", + "px": 425.5, + "dp": 212.75, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerNameGap": { + "$type": "dimension", + "px": 26, + "dp": 13.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerGearTop": { + "$type": "dimension", + "px": 751.7, + "dp": 375.85, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerGearCaptionTop": { + "$type": "dimension", + "px": 72, + "dp": 36.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerLetter": { + "$type": "dimension", + "px": 100, + "dp": 50.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerSkeletonNameWidth": { + "$type": "dimension", + "px": 140, + "dp": 70.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerSkeletonNameHeight": { + "$type": "dimension", + "px": 40, + "dp": 20.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerLoadingTitleTop": { + "$type": "dimension", + "px": 247.4, + "dp": 123.7, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerLoadingPeopleTop": { + "$type": "dimension", + "px": 400.6, + "dp": 200.3, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerLoadingButtonTop": { + "$type": "dimension", + "px": 756.6, + "dp": 378.3, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerErrorTitleTop": { + "$type": "dimension", + "px": 297.6, + "dp": 148.8, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerErrorPanelTop": { + "$type": "dimension", + "px": 450.8, + "dp": 225.4, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerErrorPanelWidth": { + "$type": "dimension", + "px": 980, + "dp": 490.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerErrorPanelHeight": { + "$type": "dimension", + "px": 185.6, + "dp": 92.8, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "pickerErrorButtonTop": { + "$type": "dimension", + "px": 706.4, + "dp": 353.2, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsHeaderTop": { + "$type": "dimension", + "px": 150, + "dp": 75.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsHeaderAvatar": { + "$type": "dimension", + "px": 52, + "dp": 26.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsHeaderGap": { + "$type": "dimension", + "px": 24, + "dp": 12.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsRowTop": { + "$type": "dimension", + "px": 10, + "dp": 5.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsRowPadding": { + "$type": "dimension", + "px": 30, + "dp": 15.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsRowGap": { + "$type": "dimension", + "px": 24, + "dp": 12.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsIcon": { + "$type": "dimension", + "px": 36, + "dp": 18.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsPillHeight": { + "$type": "dimension", + "px": 40, + "dp": 20.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsPillStroke": { + "$type": "dimension", + "px": 2, + "dp": 1.0, + "source": "index.html:122; pill border 2 px inside its 40 px border-box" + }, + "settingsPillDashRatio": { + "$type": "number", + "$value": 3, + "source": "st-091 frozen browser top border: 6 px dashes at 2 px stroke width" + }, + "settingsPillDashGapRatio": { + "$type": "number", + "$value": 2, + "source": "st-091 frozen browser top border: 4 px gaps at 2 px stroke width" + }, + "settingsPillBorder": { + "$type": "color", + "$value": "#ffffff38", + "source": "index.html:122; white at 22 percent opacity, rounded to 8-bit alpha" + }, + "settingsPillFocusedBorder": { + "$type": "color", + "$value": "#0b0b1066", + "source": "index.html:328; focused pill ink at 40 percent opacity" + }, + "settingsPillPadding": { + "$type": "dimension", + "px": 16, + "dp": 8.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsPillGap": { + "$type": "dimension", + "px": 12, + "dp": 6.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsChevron": { + "$type": "dimension", + "px": 30, + "dp": 15.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "settingsEndPadding": { + "$type": "dimension", + "px": 620, + "dp": 310.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "panelPaddingX": { + "$type": "dimension", + "px": 30, + "dp": 15.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "panelPaddingY": { + "$type": "dimension", + "px": 28, + "dp": 14.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "panelGap": { + "$type": "dimension", + "px": 10, + "dp": 5.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "panelHeadingGap": { + "$type": "dimension", + "px": 14, + "dp": 7.0, + "source": "index.html:244: receipt heading icon-to-title gap" + }, + "panelIcon": { + "$type": "dimension", + "px": 36, + "dp": 18.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "skeletonRadius": { + "$type": "dimension", + "px": 10, + "dp": 5.0, + "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" + }, + "gearOpacity": { + "$type": "number", + "$value": 0.7, + "source": "locked index.html CSS / measured st-* bounds" + }, + "navBandOpacity": { + "$type": "number", + "$value": 0.72, + "source": "locked index.html CSS / measured st-* bounds" + }, + "navSolidStop": { + "$type": "number", + "$value": 0.84, + "source": "locked index.html CSS / measured st-* bounds" + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt new file mode 100644 index 00000000..0de442ff --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt @@ -0,0 +1,21 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.tv.material3.Text + +/** Captions fade without moving the reserved band under the focused poster. */ +@Composable +fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) { + val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "caption-fade") + Column(modifier.graphicsLayer { this.alpha = alpha }) { + Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1) + Text(facts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt new file mode 100644 index 00000000..14a1cc76 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt @@ -0,0 +1,41 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp + +/** Only transforms animate: focus never changes measured geometry. */ +@Composable +fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier { + val animated by animateFloatAsState( + if (focused) scale else 1f, + tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), + label = "focus-scale", + ) + return graphicsLayer { scaleX = animated; scaleY = animated } +} + +/** Draw the ring outside the slot; neither the gap nor the ring obscures content. */ +@Composable +fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color): Modifier { + val alpha by animateFloatAsState(if (focused) 1f else 0f, + tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring") + return drawBehind { + if (alpha > 0f) { + val outer = (gap + width).toPx() + val inner = gap.toPx() + drawRoundRect(color.copy(alpha = color.alpha * alpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer)) + drawRoundRect(WolfskinTokens.Color.bg, Offset(-inner, -inner), Size(size.width + 2 * inner, size.height + 2 * inner), CornerRadius(radius.toPx() + inner)) + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt new file mode 100644 index 00000000..82fd5a45 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt @@ -0,0 +1,26 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.tv.material3.Icon + +/** Shell glyph geometry is copied from the locked prototype, not a platform icon font. */ +@Composable +fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) { + val resource = when (name) { + "gear" -> R.drawable.wolfskin_gear + "requests" -> R.drawable.wolfskin_tray + "interface" -> R.drawable.wolfskin_grid + "playback" -> R.drawable.wolfskin_play + "nextup" -> R.drawable.wolfskin_tv + "profile" -> R.drawable.wolfskin_users + "about" -> R.drawable.wolfskin_info + "seerr" -> R.drawable.wolfskin_sparkle + "advanced" -> R.drawable.wolfskin_sort + "alert" -> R.drawable.wolfskin_alert + else -> R.drawable.wolfskin_right + } + Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt new file mode 100644 index 00000000..d6255997 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt @@ -0,0 +1,39 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.tv.material3.Text + +/** Fixed household colours never depend on preference or avatar presets. */ +fun personColor(key: String): Color = when (key.lowercase()) { + "justin" -> WolfskinTokens.PersonColor.justin + "mindel" -> WolfskinTokens.PersonColor.mindel + "shared" -> WolfskinTokens.PersonColor.shared + else -> WolfskinTokens.Color.accent +} + +/** The same mark anchors the picker and the persistent navigation. */ +@Composable +fun Wordmark(modifier: Modifier = Modifier) { + Row(modifier) { + Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark) + Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark) + } +} + +/** Initials are drawn over opaque person colour at the exact slot size. */ +@Composable +fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier) { + Box(modifier.size(diameter).background(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) { + Text(person.initial, color = WolfskinTokens.Color.onFocus, style = textStyle) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt new file mode 100644 index 00000000..543ac321 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -0,0 +1,104 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.animateColorAsState +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.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +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 + +/** 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): List { + var left = L.safeSide + 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 + } + 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) +} + +/** 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 labelFade = remember { tween(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) } + val index = if (state.navFocused) state.navIndex else state.currentTab + val box = boxes[index] + val avatarFocused = state.navFocused && index == state.tabs.size + val pad = if (avatarFocused) L.navBlobAvatarPad else S.zero + val damping = WolfskinTokens.Motion.navBlobDampingRatio + val stiffness = WolfskinTokens.Motion.navBlobStiffness + val x by animateFloatAsState((box.left - pad).value, spring(damping, stiffness), label = "blob-x") + val y by animateFloatAsState((box.top - pad).value, spring(damping, stiffness), label = "blob-y") + val width by animateFloatAsState((box.width + pad * 2).value, spring(damping, stiffness), label = "blob-width") + val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height") + val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color") + val density = LocalDensity.current.density + val band = remember(scrolled) { + if (scrolled) Brush.verticalGradient(0f to C.bg, S.navSolidStop to C.bg, 1f to C.bg.copy(alpha = 0f)) + else Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f))) + } + Box(modifier.fillMaxWidth().height(L.navBand)) { + Canvas(Modifier.matchParentSize()) { + drawRect(band) + drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2)) + } + Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop)) + 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, + animationSpec = labelFade, + 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) { + Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent)) + } + } + } + val avatar = boxes.last() + Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav, + Modifier.offset(avatar.left, avatar.top) + .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) }) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt new file mode 100644 index 00000000..91bf3bb3 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt @@ -0,0 +1,128 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.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 +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +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 + +/** 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) + +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"), +) + +/** Requests is intentionally a no-data Wolfskin entry until the Requests slice. */ +@Composable +fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: Int? = null) { + val list = state.view.listState + 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(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) + } + 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) + 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) + } + } + itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group -> + Box(Modifier.height(L.row_srow)) { + SettingsRow(group, !state.navFocused && index == state.settingsRow, + if (group.key == "requests") openRequests else null, + Modifier.offset(L.safeSide, S.settingsRowTop)) { + state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok) + } + } + } + } +} + +/** The inverse focus fill has no focus ring and never changes row layout. */ +@Composable +fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) { + val foreground = if (focused) C.onFocus else C.fg + Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).focusScale(focused, WolfskinTokens.Focus.scaleRow) + .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)) + Column(Modifier.weight(1f)) { + val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else "" + 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) + } + 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)) + } +} + +@Composable +private fun SettingsPill(label: String, focused: Boolean) { + val foreground = if (focused) C.onFocus else C.dim + val border = if (focused) S.settingsPillFocusedBorder else S.settingsPillBorder + Box(Modifier.height(S.settingsPillHeight).drawWithCache { + val stroke = S.settingsPillStroke.toPx() + val inset = Offset(stroke / 2, stroke / 2) + val bounds = Size(size.width - stroke, size.height - stroke) + val outline = Stroke(stroke, pathEffect = PathEffect.dashPathEffect( + floatArrayOf(stroke * S.settingsPillDashRatio, stroke * S.settingsPillDashGapRatio), + )) + val radius = CornerRadius(WolfskinTokens.Radius.pill.toPx()) + onDrawBehind { + drawRoundRect(border, topLeft = inset, size = bounds, cornerRadius = radius, style = outline) + } + }.padding(horizontal = S.settingsPillPadding + S.settingsPillStroke), contentAlignment = Alignment.Center) { + Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight)) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt new file mode 100644 index 00000000..b1bffcba --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -0,0 +1,216 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +/** Household identity data contains no credentials or server objects. */ +data class ShellPerson(val key: String, val name: String, val initial: String, val hasAnime: Boolean) + +/** The shell owns keys; the integration owns navigation and authenticated user changes. */ +sealed interface ShellAction { + data class Destination(val route: String) : ShellAction + data class Person(val person: ShellPerson) : ShellAction + data class HandOff(val group: String) : ShellAction + data object Pop : ShellAction + data object RetryPeople : ShellAction +} + +/** D-pad inputs are shared by Android events and deterministic JVM replays. */ +enum class ShellKey { Up, Down, Left, Right, Ok, Back } +enum class PeopleStatus { Ready, Loading, Unavailable } + +/** A root destination retains focus, horizontal tracks and its actual Compose scroller. */ +class RememberedView { + val listState = LazyListState() + var row by mutableStateOf(0) + val cards = mutableStateMapOf() +} + +/** Focus and Back transitions are independent of drawing and server availability. */ +class ShellState( + val people: List, + initialPerson: String, + private val onAction: (ShellAction) -> Unit = {}, +) { + var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first()) + private set + var route by mutableStateOf("home") + private set + var pickerVisible by mutableStateOf(true) + var pickerLaunch by mutableStateOf(true) + var pickerIndex by mutableStateOf(people.indexOf(person)) + var gearFocused by mutableStateOf(false) + var navFocused by mutableStateOf(false) + var navIndex by mutableStateOf(0) + var peopleStatus by mutableStateOf(PeopleStatus.Ready) + var recoveryIndex by mutableStateOf(0) + var requestsVisible by mutableStateOf(false) + var handOff by mutableStateOf(null) + var settingsRow by mutableStateOf(0) + private var rootRoute by mutableStateOf("home") + private var pickerReturnRoute = "home" + private val views = mutableStateMapOf, RememberedView>() + private var armedCard: String? = null + val tabs: List get() = if (person.hasAnime) ANIME_TABS else BASE_TABS + val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0) + val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() } + + /** External intents and upstream pops remain authoritative over the screen. */ + fun enterDestination(destination: String) { + route = destination + if (destination in tabs) rootRoute = destination + handOff = null + } + + /** Pointer navigation uses the same transitions as D-pad selection. */ + fun focusNav(index: Int) { navIndex = index; navFocused = true } + fun focusPerson(index: Int) { pickerIndex = index; gearFocused = false } + + /** Repeats are consumed, and a row activation is cancelled when focus moves. */ + fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = null): Boolean { + if (key == ShellKey.Ok) { + if (repeat > 0) return true + if (card != null) { + if (down) armedCard = card else { + if (armedCard == card) activate() + armedCard = null + } + return true + } + if (!down) return true + } else { + if (!down) return true + armedCard = null + } + if (requestsVisible) { + if (key == ShellKey.Back || key == ShellKey.Ok) requestsVisible = false + return true + } + if (pickerVisible) pickerKey(key) else if (key == ShellKey.Back) back() else if (navFocused) navKey(key) else contentKey(key) + return true + } + + private fun pickerKey(key: ShellKey) { + if (peopleStatus != PeopleStatus.Ready) { + when (key) { + ShellKey.Left -> recoveryIndex = 0 + ShellKey.Right -> if (peopleStatus == PeopleStatus.Unavailable) recoveryIndex = 1 + ShellKey.Back -> closePicker() + ShellKey.Ok -> if (peopleStatus == PeopleStatus.Unavailable && recoveryIndex == 0) onAction(ShellAction.RetryPeople) else choosePerson(person) + else -> Unit + } + } else if (gearFocused) { + when (key) { + ShellKey.Up -> gearFocused = false + ShellKey.Ok -> { + pickerVisible = false + navFocused = false + settingsRow = 0 + route = "settings" + onAction(ShellAction.Destination(route)) + } + ShellKey.Back -> closePicker() + else -> Unit + } + } else { + when (key) { + ShellKey.Left -> pickerIndex = (pickerIndex - 1).coerceAtLeast(0) + ShellKey.Right -> pickerIndex = (pickerIndex + 1).coerceAtMost(people.lastIndex) + ShellKey.Down -> if (pickerIndex == people.lastIndex) gearFocused = true + ShellKey.Ok -> choosePerson(people[pickerIndex]) + ShellKey.Back -> closePicker() + else -> Unit + } + } + } + + private fun choosePerson(selected: ShellPerson) { + person = selected + pickerVisible = false + pickerLaunch = false + route = "home" + navFocused = false + navIndex = 0 + rootRoute = "home" + views[person.key to route] = RememberedView() + onAction(ShellAction.Person(selected)) + } + + private fun closePicker() { + if (!pickerLaunch) { pickerVisible = false; navFocused = true; navIndex = tabs.size } + } + + private fun navKey(key: ShellKey) { + when (key) { + ShellKey.Left -> navIndex = (navIndex + tabs.size) % (tabs.size + 1) + ShellKey.Right -> navIndex = (navIndex + 1) % (tabs.size + 1) + ShellKey.Down -> navFocused = false + ShellKey.Ok -> activate() + else -> Unit + } + } + + private fun contentKey(key: ShellKey) { + when (key) { + ShellKey.Up -> if (route == "settings" && settingsRow > 0) settingsRow-- else { navFocused = true; navIndex = currentTab } + ShellKey.Down -> if (route == "settings") settingsRow = (settingsRow + 1).coerceAtMost(SETTINGS_GROUPS.lastIndex) + ShellKey.Ok -> activate() + else -> Unit + } + } + + private fun activate() { + if (navFocused) { + if (navIndex == tabs.size) { + pickerReturnRoute = route + pickerVisible = true + pickerLaunch = false + pickerIndex = people.indexOf(person) + gearFocused = false + } else { + val target = tabs[navIndex] + if (target == route) views[person.key to target] = RememberedView() + route = target + rootRoute = target + navFocused = false + onAction(ShellAction.Destination(target)) + } + } else if (route == "settings") { + val group = SETTINGS_GROUPS[settingsRow].key + if (group == "requests") requestsVisible = true else { + handOff = group + onAction(ShellAction.HandOff(group)) + } + } + } + + /** Back never discards a root view, including the card below a focused nav. */ + fun back() { + if (route == "settings") { + route = pickerReturnRoute + onAction(ShellAction.Pop) + pickerVisible = true + pickerLaunch = false + gearFocused = true + pickerIndex = people.lastIndex + } else if (route !in tabs) { + onAction(ShellAction.Pop) + } else if (!navFocused) { + navFocused = true + navIndex = currentTab + } else if (route != "home") { + route = "home" + rootRoute = "home" + navIndex = 0 + onAction(ShellAction.Destination(route)) + } + } + + companion object { + private val BASE_TABS = listOf("home", "discover", "movies", "shows") + private val ANIME_TABS = BASE_TABS + "anime" + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt new file mode 100644 index 00000000..7132111f --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt @@ -0,0 +1,111 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.focusGroup +import androidx.compose.ui.focus.focusRestorer +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +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.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.onPreviewKeyEvent +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.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L + +/** Map Android key events once, preserving repeat count and key-up semantics. */ +fun ShellState.handleKey(event: KeyEvent): Boolean { + val key = when (event.key) { + Key.DirectionUp -> ShellKey.Up + Key.DirectionDown -> ShellKey.Down + Key.DirectionLeft -> ShellKey.Left + Key.DirectionRight -> ShellKey.Right + Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> ShellKey.Ok + Key.Back, Key.Escape -> ShellKey.Back + else -> return false + } + return this.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount) +} + +/** Content and nav share a single focus policy; upstream full-screen pages bypass this surface. */ +@Composable +fun ShellSurface( + state: ShellState, + modifier: Modifier = Modifier, + handlesContentKeys: Boolean = true, + settingsOpenRequests: Int? = null, + content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) }, +) { + val focus = remember { FocusRequester() } + val contentFocus = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) { + if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus() + else contentFocus.requestFocus() + } + 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}"}" } + .focusRequester(focus) + .onPreviewKeyEvent { + if (state.pickerVisible || state.navFocused || state.requestsVisible || handlesContentKeys) state.handleKey(it) else false + } + .onKeyEvent { + if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) { + if (!focusManager.moveFocus(FocusDirection.Up)) { + state.navFocused = true + state.navIndex = state.currentTab + } + true + } else false + }.focusable()) { + if (state.pickerVisible) { + WhosWatching(state) + } else { + if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) + NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2) + if (state.requestsVisible) { + Box(Modifier.fillMaxSize().background(C.sheetScrim)) { + Column(Modifier.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth) + .width(L.sheetWidth).fillMaxSize().background(C.surface).padding(L.safeSide), + verticalArrangement = Arrangement.spacedBy(L.buttonGap)) { + Text("Requests", color = C.fg, style = WolfskinTokens.Type.sheetTitle) + Text("Wolfskin screen", color = C.accentText, style = WolfskinTokens.Type.label) + Text("No request data yet. Conductarr sign-in is not available on this TV.", color = C.muted, style = WolfskinTokens.Type.body) + PickerButton("Close", true, true) { state.requestsVisible = false } + } + } + } + } + } +} + +/** Future screens are explicitly out of scope, never simulated library or title data. */ +@Composable +fun ShellPlaceholder(route: String, modifier: Modifier = Modifier) { + Column(modifier.offset(L.safeSide, L.detailCopyTop), verticalArrangement = Arrangement.spacedBy(L.buttonGap)) { + Text(route.replaceFirstChar { it.uppercase() }, color = C.fg, style = WolfskinTokens.Type.screenTitle) + Text("Wolfskin screen — not built in this slice", color = C.accentText, style = WolfskinTokens.Type.body) + Text("Home and Library remain available through the Wolfskin screens switch in Advanced settings.", color = C.muted, style = WolfskinTokens.Type.body) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt new file mode 100644 index 00000000..1bef6689 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt @@ -0,0 +1,113 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.requiredWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 + +/** The picker covers the nav and exposes Settings only below the final person. */ +@Composable +fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) { + Box(modifier.fillMaxSize().background(C.bg)) { + Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker)) + if (state.peopleStatus == PeopleStatus.Ready) PeoplePicker(state) else RecoveryPicker(state) + } +} + +@Composable +private fun PeoplePicker(state: ShellState) { + Box(Modifier.fillMaxWidth().offset(y = S.pickerTitleTop), contentAlignment = Alignment.TopCenter) { + Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle) + } + Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) { + state.people.forEachIndexed { index, person -> + val focused = !state.gearFocused && state.pickerIndex == index + Column(Modifier.width(L.pickerAvatar).focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { + state.focusPerson(index); state.key(ShellKey.Ok) + }, horizontalAlignment = Alignment.CenterHorizontally) { + Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker, + Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerGap, WolfskinTokens.Focus.ringPickerRing, C.fg)) + Spacer(Modifier.height(S.pickerNameGap)) + Text(person.name, color = if (focused) C.fg else C.muted, style = WolfskinTokens.Type.pickerName) + } + } + } + val total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1) + val left = (S.stageWidth - total) / 2 + (L.pickerAvatar + L.pickerGap) * state.people.lastIndex + (L.pickerAvatar - L.gear) / 2 + Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard) + .focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg) + .clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { + Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon)) + if (state.gearFocused) { + Box(Modifier.align(Alignment.TopCenter).offset(y = S.pickerGearCaptionTop).requiredWidth(L.pickerAvatar), contentAlignment = Alignment.TopCenter) { + Text("Settings", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false) + } + } + } +} + +@Composable +private fun RecoveryPicker(state: ShellState) { + val loading = state.peopleStatus == PeopleStatus.Loading + val titleTop = if (loading) S.pickerLoadingTitleTop else S.pickerErrorTitleTop + Box(Modifier.fillMaxWidth().offset(y = titleTop), contentAlignment = Alignment.TopCenter) { + Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle) + } + if (loading) { + Row(Modifier.fillMaxWidth().offset(y = S.pickerLoadingPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) { + repeat(state.people.size) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.size(L.pickerAvatar).background(C.surface2, CircleShape)) + Spacer(Modifier.height(S.pickerNameGap)) + Box(Modifier.size(S.pickerSkeletonNameWidth, S.pickerSkeletonNameHeight).background(C.surface, RoundedCornerShape(S.skeletonRadius))) + } + } + } + } else { + Column(Modifier.offset((S.stageWidth - S.pickerErrorPanelWidth) / 2, S.pickerErrorPanelTop) + .size(S.pickerErrorPanelWidth, S.pickerErrorPanelHeight).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)) + .padding(horizontal = S.panelPaddingX, vertical = S.panelPaddingY), verticalArrangement = Arrangement.spacedBy(S.panelGap)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.panelHeadingGap)) { + Glyph("alert", C.warn, Modifier.size(S.panelIcon)) + Text("Can’t read who’s here", color = C.warn, style = WolfskinTokens.Type.stateTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold)) + } + Text("Conductarr didn’t answer. You can continue as the last person on\nthis TV.", color = C.muted, style = WolfskinTokens.Type.body) + } + } + Row(Modifier.fillMaxWidth().offset(y = if (loading) S.pickerLoadingButtonTop else S.pickerErrorButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap, Alignment.CenterHorizontally)) { + if (!loading) PickerButton("Try again", state.recoveryIndex == 0, true) { state.recoveryIndex = 0; state.key(ShellKey.Ok) } + PickerButton("Continue as ${state.person.name}", loading || state.recoveryIndex == 1, false) { state.recoveryIndex = 1; state.key(ShellKey.Ok) } + } +} + +/** Recovery actions remain focusable while profiles cannot be read. */ +@Composable +fun PickerButton(label: String, focused: Boolean, primary: Boolean, onClick: () -> Unit) { + val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary + Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) + .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent) + .background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick) + .padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) { + Text(label, color = if (primary) C.onFocus else C.fg, style = WolfskinTokens.Type.button) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt new file mode 100644 index 00000000..d3e155a8 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt @@ -0,0 +1,41 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.Composable +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.darkColorScheme + +/** Both Compose Material families use the same opaque TV palette for hand-offs. */ +object WolfskinColors { + private val c = WolfskinTokens.Color + val darkScheme = darkColorScheme( + primary = c.accent, onPrimary = c.onFocus, + primaryContainer = c.accentSoft, onPrimaryContainer = c.accentText, + secondary = c.accent, onSecondary = c.onFocus, + secondaryContainer = c.accentSoft, onSecondaryContainer = c.accentText, + tertiary = c.accent, onTertiary = c.onFocus, + tertiaryContainer = c.accentSoft, onTertiaryContainer = c.accentText, + background = c.bg, onBackground = c.fg, + surface = c.bg, onSurface = c.fg, + surfaceVariant = c.surface2, onSurfaceVariant = c.fg, + border = c.accent, + ) + val darkSchemeMaterial = androidx.compose.material3.darkColorScheme( + primary = c.accent, onPrimary = c.onFocus, + primaryContainer = c.accentSoft, onPrimaryContainer = c.accentText, + secondary = c.accent, onSecondary = c.onFocus, + secondaryContainer = c.accentSoft, onSecondaryContainer = c.accentText, + tertiary = c.accent, onTertiary = c.onFocus, + tertiaryContainer = c.accentSoft, onTertiaryContainer = c.accentText, + background = c.bg, onBackground = c.fg, + surface = c.bg, onSurface = c.fg, + surfaceVariant = c.surface2, onSurfaceVariant = c.fg, + ) +} + +/** Upstream content inherits tokens without a fork-specific theme enum. */ +@Composable +fun WolfskinTheme(content: @Composable () -> Unit) { + androidx.compose.material3.MaterialTheme(colorScheme = WolfskinColors.darkSchemeMaterial) { + MaterialTheme(colorScheme = WolfskinColors.darkScheme, content = content) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt new file mode 100644 index 00000000..e4071964 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -0,0 +1,288 @@ +// Generated by generate_tokens.py; edit the JSON sources instead. +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.graphics.Color as ComposeColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp + +/** The locked TV design, converted at the source density. */ +object WolfskinTokens { + object Color { + val bg = ComposeColor(0xFF07070A) + val surface = ComposeColor(0xFF13131A) + val surface2 = ComposeColor(0xFF1B1B25) + val surface3 = ComposeColor(0xFF252532) + val fg = ComposeColor(0xFFF4F4F6) + val muted = ComposeColor(0xBDF4F4F6) + val dim = ComposeColor(0x8FF4F4F6) + val border = ComposeColor(0x1AFFFFFF) + val accent = ComposeColor(0xFFCC9FFF) + val accentSoft = ComposeColor(0xFF2C1A3F) + val accentText = ComposeColor(0xFFE4CBFF) + val ok = ComposeColor(0xFF89DA9B) + val warn = ComposeColor(0xFFF4C26A) + val fail = ComposeColor(0xFFF97770) + val onFocus = ComposeColor(0xFF0B0B10) + val buttonSecondary = ComposeColor(0x24FFFFFF) + val buttonSecondaryFocused = ComposeColor(0x42FFFFFF) + val titleButtonSecondary = ComposeColor(0xC72C2C38) + val titleButtonSecondaryFocused = ComposeColor(0xE6464654) + val chipRest = ComposeColor(0x1AFFFFFF) + val navBlobRest = ComposeColor(0x1AFFFFFF) + val listRowRest = ComposeColor(0x0DFFFFFF) + val sheetScrim = ComposeColor(0x9E000000) + val badgeRest = ComposeColor(0xBF07070A) + val badgeWatched = ComposeColor(0xD9F4F4F6) + val progressTrack = ComposeColor(0x38FFFFFF) + val railEmpty = ComposeColor(0x38F4F4F6) + } + object PersonColor { + val justin = ComposeColor(0xFF62C4FF) + val mindel = ComposeColor(0xFFFF9D72) + val shared = ComposeColor(0xFF9BE38F) + } + object Gradient { + const val heroScrim = "linear-gradient(90deg,rgb(7 7 10 / .92) 0%,rgb(7 7 10 / .55) 42%,rgb(7 7 10 / 0) 70%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .6) 26%,rgb(7 7 10 / 0) 48%)" + const val detailScrim = "linear-gradient(90deg,rgb(7 7 10 / .94) 0%,rgb(7 7 10 / .6) 46%,rgb(7 7 10 / .05) 75%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .35) 34%,rgb(7 7 10 / 0) 55%)" + const val navBand = "linear-gradient(180deg,rgb(7 7 10 / .72),rgb(7 7 10 / 0))" + const val navBandSolid = "linear-gradient(180deg,var(--bg) 0%,var(--bg) 84%,rgb(7 7 10 / 0) 100%)" + const val ratingPlate = "linear-gradient(180deg,rgb(7 7 10 / .9) 0%,rgb(7 7 10 / .72) 52%,rgb(7 7 10 / 0) 100%)" + const val tileOverlay = "linear-gradient(0deg,rgb(7 7 10 / .92),rgb(7 7 10 / 0))" + const val fallbackArt = "linear-gradient(160deg,hsl(var(--h,260) 22% 22%),hsl(var(--h,260) 26% 10%))" + const val entryTile = "linear-gradient(150deg,hsl(var(--h) 30% 26%),hsl(var(--h) 30% 12%))" + const val initialsCircle = "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))" + const val heroAmbientFilter = "blur(60px) saturate(1.2) brightness(.55)" + const val cardAmbientFilter = "blur(26px) brightness(.5)" + } + object Type { + val displayDetail = TextStyle(fontSize = 44.0.sp, fontWeight = FontWeight(800), lineHeight = 44.88.sp, letterSpacing = -0.02.em) + val displayHero = TextStyle(fontSize = 42.0.sp, fontWeight = FontWeight(800), lineHeight = 42.84.sp, letterSpacing = -0.02.em) + val screenTitle = TextStyle(fontSize = 32.0.sp, fontWeight = FontWeight(800), lineHeight = 41.6.sp, letterSpacing = -0.02.em) + val sectionTitle = TextStyle(fontSize = 28.0.sp, fontWeight = FontWeight(800), lineHeight = 36.4.sp, letterSpacing = -0.02.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 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.Bold, lineHeight = 22.1.sp, letterSpacing = -0.01.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.Bold, 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 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.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 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 avatarLetterNav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800)) + val avatarLetterSheetRow = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800)) + val avatarLetterShelfHead = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(800)) + val avatarLetterChip = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(800)) + val avatarLetterLibHeader = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(800)) + val avatarLetterSectionHeader = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800)) + const val stageLineHeight = 1.3f + } + object Radius { + val card = 8.0.dp + val button = 19.0.dp + val chip = 17.0.dp + val navItem = 16.0.dp + val listRow = 12.0.dp + val panel = 11.0.dp + val menu = 13.0.dp + val heroSheet = 9.0.dp + val cardSheet = 5.0.dp + val thumb = 6.0.dp + val pill = 11.0.dp + val progress = 2.0.dp + } + object Layout { + val safeSide = 48.0.dp + val contentWidth = 864.0.dp + val safeTopPicker = 27.0.dp + val navBand = 75.0.dp + val navPaddingTop = 22.0.dp + val navItemHeight = 32.0.dp + val navItemPaddingX = 13.0.dp + val navItemGap = 6.0.dp + val wordmarkGap = 20.0.dp + val navUnderline = 2.0.dp + val navBlobAvatarPad = 4.0.dp + val navSolidAfter = 20.0.dp + val pinnedLine = 84.0.dp + val windowAhead = 350.0.dp + val rowHeadingBand = 30.0.dp + val rowGapAfter = 22.0.dp + val gridRowGap = 14.0.dp + val row_hero = 410.0.dp + val row_phero = 370.0.dp + val row_section = 75.0.dp + val row_chips = 52.0.dp + val row_note = 42.0.dp + val row_state = 150.0.dp + val row_lhead = 118.0.dp + val row_dhead = 450.0.dp + val row_phead = 235.0.dp + val row_gh = 42.0.dp + val row_endcta = 100.0.dp + val row_srow = 62.0.dp + val heroCopyLeft = 48.0.dp + val heroCopyBottom = 55.0.dp + val heroCopyWidth = 500.0.dp + val heroOverviewMax = 480.0.dp + val detailCopyTop = 100.0.dp + val detailCopyWidth = 530.0.dp + val heroSheetWidth = 210.0.dp + val heroSheetHeight = 315.0.dp + val buttonHeight = 38.0.dp + val buttonPaddingX = 19.0.dp + val buttonGap = 10.0.dp + val buttonIcon = 15.0.dp + val iconButton = 38.0.dp + val iconCaptionLine = 19.0.dp + val sheetWidth = 400.0.dp + val sheetRow = 50.0.dp + val settingsRowWidth = 650.0.dp + val settingsRowHeight = 52.0.dp + val stateWidth = 550.0.dp + val captionOffset = 9.0.dp + val badge = 24.0.dp + val badgeInset = 7.0.dp + val progressBar = 4.0.dp + val pickerAvatar = 110.0.dp + val pickerGap = 45.0.dp + val pickerStackGap = 35.0.dp + val gear = 28.0.dp + val navAvatar = 32.0.dp + val castCircle = 100.0.dp + val personPortrait = 120.0.dp + val thumbWidth = 48.0.dp + val thumbHeight = 72.0.dp + } + object Card { + val posterW = 128.0.dp + val posterH = 192.0.dp + val posterGap = 12.0.dp + val posterPitch = 140.0.dp + val posterCaptionBand = 38.0.dp + val tileW = 208.0.dp + val tileH = 117.0.dp + val tileGap = 12.0.dp + val tilePitch = 220.0.dp + val tileCaptionBand = 0.0.dp + val wideW = 280.0.dp + val wideH = 157.5.dp + val wideGap = 12.0.dp + val widePitch = 292.0.dp + val wideCaptionBand = 0.0.dp + val entryW = 128.0.dp + val entryH = 72.0.dp + val entryGap = 12.0.dp + val entryPitch = 140.0.dp + val entryCaptionBand = 0.0.dp + val personW = 100.0.dp + val personH = 145.0.dp + val personGap = 14.0.dp + val personPitch = 114.0.dp + val personCaptionBand = 0.0.dp + } + object Focus { + const val scaleCard = 1.08f + const val scaleControl = 1.05f + const val scaleRow = 1.02f + val ringCardGap = 2.0.dp + val ringCardRing = 2.0.dp + val ringButtonGap = 2.0.dp + val ringButtonRing = 2.0.dp + val ringAvatarGap = 2.0.dp + val ringAvatarRing = 2.0.dp + val ringPickerGap = 3.0.dp + val ringPickerRing = 3.0.dp + const val fillInverse = "background color.fg, text color.onFocus" + } + object Motion { + const val fast = 140 + const val base = 160 + const val scroll = 180 + const val navBlobDampingRatio = 1.0f + const val navBlobStiffness = 650.0f + const val ratingDwell = 350 + const val toast = 3200 + const val reducedMotion = "all transitions instant" + } + object Image { + const val memoryCacheMB = 64.0f + const val diskCacheMB = 128.0f + } + object Behaviour { + const val rowLimit = 20.0f + const val letterRailMin = 200.0f + const val gridColumns = 6.0f + const val libraryChunk = 200.0f + const val laneLoadRows = 2.0f + const val nextPageCards = 10.0f + const val heroSlides = 5.0f + const val cacheRatingsHours = 6.0f + const val laneCacheMinutes = 30.0f + const val libraryRefreshMinutes = 10.0f + const val requestsPollSeconds = 30.0f + const val comingInVisibleRows = 5.0f + } + object Shell { + const val navLabelFade = 200 + val zero = 0.0.dp + val stageWidth = 960.0.dp + val stageHeight = 540.0.dp + val wordmarkWidth = 84.5.dp + val wordmarkTop = 36.8.dp + val navHomeWidth = 74.5.dp + val navDiscoverWidth = 99.0.dp + val navMoviesWidth = 85.5.dp + val navShowsWidth = 80.5.dp + val navAnimeWidth = 79.0.dp + val navItemTop = 32.5.dp + val pickerTitleTop = 136.15.dp + val pickerPeopleTop = 212.75.dp + val pickerNameGap = 13.0.dp + val pickerGearTop = 375.85.dp + val pickerGearCaptionTop = 36.0.dp + val pickerLetter = 50.0.dp + val pickerSkeletonNameWidth = 70.0.dp + val pickerSkeletonNameHeight = 20.0.dp + val pickerLoadingTitleTop = 123.7.dp + val pickerLoadingPeopleTop = 200.3.dp + val pickerLoadingButtonTop = 378.3.dp + val pickerErrorTitleTop = 148.8.dp + val pickerErrorPanelTop = 225.4.dp + val pickerErrorPanelWidth = 490.0.dp + val pickerErrorPanelHeight = 92.8.dp + val pickerErrorButtonTop = 353.2.dp + val settingsHeaderTop = 75.0.dp + val settingsHeaderAvatar = 26.0.dp + val settingsHeaderGap = 12.0.dp + val settingsRowTop = 5.0.dp + val settingsRowPadding = 15.0.dp + val settingsRowGap = 12.0.dp + val settingsIcon = 18.0.dp + val settingsPillHeight = 20.0.dp + val settingsPillStroke = 1.0.dp + const val settingsPillDashRatio = 3.0f + const val settingsPillDashGapRatio = 2.0f + val settingsPillBorder = ComposeColor(0x38FFFFFF) + val settingsPillFocusedBorder = ComposeColor(0x660B0B10) + val settingsPillPadding = 8.0.dp + val settingsPillGap = 6.0.dp + val settingsChevron = 15.0.dp + val settingsEndPadding = 310.0.dp + val panelPaddingX = 15.0.dp + val panelPaddingY = 14.0.dp + val panelGap = 5.0.dp + val panelHeadingGap = 7.0.dp + val panelIcon = 18.0.dp + val skeletonRadius = 5.0.dp + const val gearOpacity = 0.7f + const val navBandOpacity = 0.72f + const val navSolidStop = 0.84f + } +} diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_alert.xml b/wolfskin-design/src/main/res/drawable/wolfskin_alert.xml new file mode 100644 index 00000000..ff7d9894 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_alert.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_gear.xml b/wolfskin-design/src/main/res/drawable/wolfskin_gear.xml new file mode 100644 index 00000000..b823a403 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_gear.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml b/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml new file mode 100644 index 00000000..e19e2857 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_info.xml b/wolfskin-design/src/main/res/drawable/wolfskin_info.xml new file mode 100644 index 00000000..7bcf471d --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_info.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_play.xml b/wolfskin-design/src/main/res/drawable/wolfskin_play.xml new file mode 100644 index 00000000..96af58e1 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_play.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_right.xml b/wolfskin-design/src/main/res/drawable/wolfskin_right.xml new file mode 100644 index 00000000..7a1835b2 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_right.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_sort.xml b/wolfskin-design/src/main/res/drawable/wolfskin_sort.xml new file mode 100644 index 00000000..fd08bb9f --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_sort.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_sparkle.xml b/wolfskin-design/src/main/res/drawable/wolfskin_sparkle.xml new file mode 100644 index 00000000..aa2679ce --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_sparkle.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_tray.xml b/wolfskin-design/src/main/res/drawable/wolfskin_tray.xml new file mode 100644 index 00000000..b902f2fd --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_tray.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_tv.xml b/wolfskin-design/src/main/res/drawable/wolfskin_tv.xml new file mode 100644 index 00000000..52f1c851 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_tv.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_users.xml b/wolfskin-design/src/main/res/drawable/wolfskin_users.xml new file mode 100644 index 00000000..adbd06d3 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_users.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/NavSpringTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/NavSpringTest.kt new file mode 100644 index 00000000..07dec9a8 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/NavSpringTest.kt @@ -0,0 +1,38 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.TargetBasedAnimation +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.spring +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Sample the shipped Compose spring, not a separately approximated motion function. */ +class NavSpringTest { + @Test + fun criticallyDampedBlobMatchesLockedCurveWithoutOvershoot() { + val animation = TargetBasedAnimation( + animationSpec = spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness), + typeConverter = Float.VectorConverter, + initialValue = 478f, + targetValue = 688f, + ) + val times = listOf(50, 99, 150, 200) + val expected = listOf(554.5f, 628.7f, 665.9f, 680.2f) + val values = times.map { animation.getValueFromNanos(it * 1_000_000L) } + values.zip(expected).forEach { (actual, target) -> assertEquals(target, actual, .2f) } + assertTrue((values[1] - 478) / 210 in .55f.. .8f) + assertEquals(688f, animation.getValueFromNanos(300_000_000), 1f) + var previous = 478f + for (millisecond in 0..1000) { + val value = animation.getValueFromNanos(millisecond * 1_000_000L) + assertTrue("Spring reversed at $millisecond ms", value >= previous) + assertTrue("Spring overshot at $millisecond ms", value <= 688f) + previous = value + } + val directory = File(System.getProperty("wolfskin.captureDirectory")) + directory.mkdirs() + File(directory, "nav-spring.json").writeText(times.zip(values).joinToString(prefix = "[", postfix = "]") { (time, value) -> "{\"ms\":$time,\"x\":$value}" }) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt new file mode 100644 index 00000000..62d40759 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt @@ -0,0 +1,81 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.text.TextLayoutResult +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.json.JSONObject +import org.junit.Before +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Native 2.0-density captures are compared against the frozen browser states outside the build tree. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ShellCaptureTest { + @get:Rule val rule = createAndroidComposeRule() + + @Before + fun fullscreen() { + 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 + } + + private fun capture(name: String, openRequests: Int? = null, configure: (ShellState) -> Unit = {}) { + val state = ShellState(fixturePeople(), "justin") + configure(state) + rule.setContent { + WolfskinTheme { + ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsOpenRequests = openRequests) + } + } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + val directory = File(System.getProperty("wolfskin.captureDirectory")) + directory.mkdirs() + rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) + } + + @Test fun st001_launch() = capture("st-001") + @Test fun st002_gear() { + capture("st-002") { it.pickerIndex = 2; it.gearFocused = true } + val layouts = mutableListOf() + rule.onNodeWithText("Settings").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) } + assertEquals("The caption must not wrap under the small gear", 1, layouts.single().lineCount) + } + @Test fun st003_loading() = capture("st-003") { it.peopleStatus = PeopleStatus.Loading } + @Test fun st004_unavailable() = capture("st-004") { it.peopleStatus = PeopleStatus.Unavailable } + @Test fun st010_homeNav() = capture("st-010") { it.pickerVisible = false; it.navFocused = true } + @Test fun st011_rememberedNav() = capture("st-011") { it.pickerVisible = false; it.navFocused = true; it.view.row = 4; it.view.cards["row-4"] = 2 } + @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", openRequests = 5) { it.pickerVisible = false; it.enterDestination("settings") } + @Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 } +} + +/** Tests and debug builds share the same shell-only projection of the locked fixture. */ +fun fixturePeople(): List { + val json = ShellCaptureTest::class.java.getResourceAsStream("/wolfskin-shell.json")!!.bufferedReader().use { JSONObject(it.readText()) } + val people = json.getJSONArray("profiles") + return List(people.length()) { index -> + val person = people.getJSONObject(index) + ShellPerson(person.getString("key"), person.getString("name"), person.getString("initial"), person.getJSONObject("destinations").has("Anime")) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt new file mode 100644 index 00000000..77052d6f --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt @@ -0,0 +1,114 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.view.KeyEvent as AndroidKeyEvent +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Press counts exercise the actual Android-to-shell event path, not a second focus implementation. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class ShellKeysTest { + @get:Rule val rule = createComposeRule() + private val state = ShellState(fixturePeople(), "justin") + private val settings = listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter, Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionCenter) + + private fun replay(keys: List, presses: Int) { + assertEquals("Acceptance press count", presses, keys.size) + rule.setContent { WolfskinTheme { ShellSurface(state) } } + keys.forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } } + } + + @Test fun switchPersonInSixPresses() { + replay(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter, Key.DirectionRight, Key.DirectionCenter), 6) + assertEquals("mindel", state.person.key) + assertEquals("home", state.route) + assertFalse(state.pickerVisible) + assertFalse(state.navFocused) + assertFalse(state.tabs.contains("anime")) + } + + @Test fun openSettingsInEightPresses() { + replay(settings, 8) + assertEquals("settings", state.route) + assertEquals(0, state.settingsRow) + assertFalse(state.pickerVisible) + assertFalse("Requests content, not the nav, must own focus", state.navFocused) + } + + @Test fun settingsBackBackInTenPresses() { + replay(settings + listOf(Key.Back, Key.Back), 10) + assertEquals("home", state.route) + assertFalse(state.pickerVisible) + assertTrue(state.navFocused) + assertEquals(state.tabs.size, state.navIndex) + } + + @Test fun playbackHandOffInElevenPresses() { + replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11) + assertEquals("playback", state.handOff) + assertEquals(2, state.settingsRow) + } + + @Test fun onlyLastPersonReachesGearAndUpReturnsThere() { + replay(listOf(Key.DirectionDown, Key.DirectionRight, Key.DirectionDown), 3) + assertFalse(state.gearFocused) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionDown) } + assertTrue(state.gearFocused) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp) } + assertFalse(state.gearFocused) + assertEquals(2, state.pickerIndex) + } + + @Test fun rootBackAndNavDownPreserveViewAndWrap() { + replay(listOf(Key.DirectionCenter), 1) + val view = state.view + rule.runOnIdle { view.row = 4; view.cards["row-4"] = 2 } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back); pressKey(Key.DirectionDown) } + assertSame(view, state.view) + assertEquals(4, state.view.row) + assertEquals(2, state.view.cards["row-4"]) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp); pressKey(Key.DirectionLeft) } + assertEquals(state.tabs.size, state.navIndex) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight) } + assertEquals(0, state.navIndex) + } + + @Test fun destinationViewsAreIsolatedAndReselectResets() { + replay(listOf(Key.DirectionCenter), 1) + rule.runOnIdle { + state.enterDestination("movies") + val movies = state.view + movies.row = 3; movies.cards["movies"] = 2 + state.enterDestination("shows") + state.view.row = 1 + state.enterDestination("movies") + assertSame(movies, state.view) + state.focusNav(2); state.key(ShellKey.Ok) + assertNotSame(movies, state.view) + assertEquals(0, state.view.row) + assertTrue(state.view.cards.isEmpty()) + } + } + + @Test fun heldOkCannotActivateNewOverlay() { + replay(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft), 3) + rule.runOnIdle { + state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) + repeat(5) { repeat -> state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, repeat + 1))) } + state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0))) + } + assertTrue(state.pickerVisible) + assertEquals("justin", state.person.key) + } +} diff --git a/wolfskin-design/src/test/resources/wolfskin-shell.json b/wolfskin-design/src/test/resources/wolfskin-shell.json new file mode 100644 index 00000000..d99323a9 --- /dev/null +++ b/wolfskin-design/src/test/resources/wolfskin-shell.json @@ -0,0 +1,78 @@ +{ + "source": "Locked Opus fixture/fixture.json; shell-only projection; debug/test only", + "profiles": [ + { + "key": "justin", + "name": "Justin", + "initial": "J", + "color": "#62c4ff", + "destinations": { + "Home": { + "kind": "home" + }, + "Movies": { + "kind": "library", + "collection": "movies" + }, + "Shows": { + "kind": "library", + "collection": "shows" + }, + "Anime": { + "kind": "library", + "collection": "anime" + }, + "Discover": { + "kind": "discover", + "collection": "discover" + } + } + }, + { + "key": "mindel", + "name": "Mindel", + "initial": "M", + "color": "#ff9d72", + "destinations": { + "Home": { + "kind": "home" + }, + "Movies": { + "kind": "library", + "collection": "movies" + }, + "Shows": { + "kind": "library", + "collection": "shows" + }, + "Discover": { + "kind": "discover", + "collection": "discover" + } + } + }, + { + "key": "shared", + "name": "Shared", + "initial": "S", + "color": "#9be38f", + "destinations": { + "Home": { + "kind": "home" + }, + "Movies": { + "kind": "library", + "collection": "movies" + }, + "Shows": { + "kind": "library", + "collection": "shows" + }, + "Discover": { + "kind": "discover", + "collection": "discover" + } + } + } + ] +} diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css new file mode 100644 index 00000000..47b010b0 --- /dev/null +++ b/wolfskin-design/tokens.css @@ -0,0 +1,179 @@ +/* Generated from the locked TV token sources. */ +:root { + --color-bg: #07070a; + --color-surface: #13131a; + --color-surface2: #1b1b25; + --color-surface3: #252532; + --color-fg: #f4f4f6; + --color-muted: #f4f4f6bd; + --color-dim: #f4f4f68f; + --color-border: #ffffff1a; + --color-accent: #cc9fff; + --color-accentSoft: #2c1a3f; + --color-accentText: #e4cbff; + --color-ok: #89da9b; + --color-warn: #f4c26a; + --color-fail: #f97770; + --color-onFocus: #0b0b10; + --color-buttonSecondary: #ffffff24; + --color-buttonSecondaryFocused: #ffffff42; + --color-titleButtonSecondary: #2c2c38c7; + --color-titleButtonSecondaryFocused: #464654e6; + --color-chipRest: #ffffff1a; + --color-navBlobRest: #ffffff1a; + --color-listRowRest: #ffffff0d; + --color-sheetScrim: #0000009e; + --color-badgeRest: #07070abf; + --color-badgeWatched: #f4f4f6d9; + --color-progressTrack: #ffffff38; + --color-railEmpty: #f4f4f638; + --personColor-justin: #62c4ff; + --personColor-mindel: #ff9d72; + --personColor-shared: #9be38f; + --gradient-heroScrim: linear-gradient(90deg,rgb(7 7 10 / .92) 0%,rgb(7 7 10 / .55) 42%,rgb(7 7 10 / 0) 70%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .6) 26%,rgb(7 7 10 / 0) 48%); + --gradient-detailScrim: linear-gradient(90deg,rgb(7 7 10 / .94) 0%,rgb(7 7 10 / .6) 46%,rgb(7 7 10 / .05) 75%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .35) 34%,rgb(7 7 10 / 0) 55%); + --gradient-navBand: linear-gradient(180deg,rgb(7 7 10 / .72),rgb(7 7 10 / 0)); + --gradient-navBandSolid: linear-gradient(180deg,var(--bg) 0%,var(--bg) 84%,rgb(7 7 10 / 0) 100%); + --gradient-ratingPlate: linear-gradient(180deg,rgb(7 7 10 / .9) 0%,rgb(7 7 10 / .72) 52%,rgb(7 7 10 / 0) 100%); + --gradient-tileOverlay: linear-gradient(0deg,rgb(7 7 10 / .92),rgb(7 7 10 / 0)); + --gradient-fallbackArt: linear-gradient(160deg,hsl(var(--h,260) 22% 22%),hsl(var(--h,260) 26% 10%)); + --gradient-entryTile: linear-gradient(150deg,hsl(var(--h) 30% 26%),hsl(var(--h) 30% 12%)); + --gradient-initialsCircle: linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%)); + --type-displayDetail: 88.0px; + --type-displayHero: 84.0px; + --type-screenTitle: 64.0px; + --type-sectionTitle: 56.0px; + --type-sheetTitle: 44.0px; + --type-wordmark: 36.0px; + --type-stateTitle: 36.0px; + --type-rowHeading: 34.0px; + --type-pickerName: 34.0px; + --type-nav: 30.0px; + --type-listTitle: 30.0px; + --type-body: 28.0px; + --type-button: 28.0px; + --type-label: 26.0px; + --type-caption: 24.0px; + --type-ratingPlate: 28.0px; + --radius-card: 16.0px; + --radius-button: 38.0px; + --radius-chip: 34.0px; + --radius-navItem: 32.0px; + --radius-listRow: 24.0px; + --radius-panel: 22.0px; + --radius-menu: 26.0px; + --radius-heroSheet: 18.0px; + --radius-cardSheet: 10.0px; + --radius-thumb: 12.0px; + --radius-pill: 22.0px; + --radius-progress: 4.0px; + --layout-safeSide: 96.0px; + --layout-contentWidth: 1728.0px; + --layout-safeTopPicker: 54.0px; + --layout-navBand: 150.0px; + --layout-navPaddingTop: 44.0px; + --layout-navItemHeight: 64.0px; + --layout-navItemPaddingX: 26.0px; + --layout-navItemGap: 12.0px; + --layout-wordmarkGap: 40.0px; + --layout-navUnderline: 4.0px; + --layout-navBlobAvatarPad: 8.0px; + --layout-navSolidAfter: 40.0px; + --layout-pinnedLine: 168.0px; + --layout-windowAhead: 700.0px; + --layout-rowHeadingBand: 60.0px; + --layout-rowGapAfter: 44.0px; + --layout-gridRowGap: 28.0px; + --layout-row_hero: 820.0px; + --layout-row_phero: 740.0px; + --layout-row_section: 150.0px; + --layout-row_chips: 104.0px; + --layout-row_note: 84.0px; + --layout-row_state: 300.0px; + --layout-row_lhead: 236.0px; + --layout-row_dhead: 900.0px; + --layout-row_phead: 470.0px; + --layout-row_gh: 84.0px; + --layout-row_endcta: 200.0px; + --layout-row_srow: 124.0px; + --layout-heroCopyLeft: 96.0px; + --layout-heroCopyBottom: 110.0px; + --layout-heroCopyWidth: 1000.0px; + --layout-heroOverviewMax: 960.0px; + --layout-detailCopyTop: 200.0px; + --layout-detailCopyWidth: 1060.0px; + --layout-buttonHeight: 76.0px; + --layout-buttonPaddingX: 38.0px; + --layout-buttonGap: 20.0px; + --layout-buttonIcon: 30.0px; + --layout-iconButton: 76.0px; + --layout-iconCaptionLine: 38.0px; + --layout-sheetWidth: 800.0px; + --layout-sheetRow: 100.0px; + --layout-stateWidth: 1100.0px; + --layout-captionOffset: 18.0px; + --layout-badge: 48.0px; + --layout-badgeInset: 14.0px; + --layout-progressBar: 8.0px; + --layout-pickerAvatar: 220.0px; + --layout-pickerGap: 90.0px; + --layout-pickerStackGap: 70.0px; + --layout-gear: 56.0px; + --layout-navAvatar: 64.0px; + --layout-castCircle: 200.0px; + --layout-personPortrait: 240.0px; + --motion-fast: 140ms; + --motion-base: 160ms; + --motion-scroll: 180ms; + --motion-ratingDwell: 350ms; + --motion-toast: 3200ms; + --shell-navLabelFade: 200ms; + --shell-zero: 0.0px; + --shell-stageWidth: 1920.0px; + --shell-stageHeight: 1080.0px; + --shell-wordmarkWidth: 169.0px; + --shell-wordmarkTop: 73.6px; + --shell-navHomeWidth: 149.0px; + --shell-navDiscoverWidth: 198.0px; + --shell-navMoviesWidth: 171.0px; + --shell-navShowsWidth: 161.0px; + --shell-navAnimeWidth: 158.0px; + --shell-navItemTop: 65.0px; + --shell-pickerTitleTop: 272.3px; + --shell-pickerPeopleTop: 425.5px; + --shell-pickerNameGap: 26.0px; + --shell-pickerGearTop: 751.7px; + --shell-pickerGearCaptionTop: 72.0px; + --shell-pickerLetter: 100.0px; + --shell-pickerSkeletonNameWidth: 140.0px; + --shell-pickerSkeletonNameHeight: 40.0px; + --shell-pickerLoadingTitleTop: 247.4px; + --shell-pickerLoadingPeopleTop: 400.6px; + --shell-pickerLoadingButtonTop: 756.6px; + --shell-pickerErrorTitleTop: 297.6px; + --shell-pickerErrorPanelTop: 450.8px; + --shell-pickerErrorPanelWidth: 980.0px; + --shell-pickerErrorPanelHeight: 185.6px; + --shell-pickerErrorButtonTop: 706.4px; + --shell-settingsHeaderTop: 150.0px; + --shell-settingsHeaderAvatar: 52.0px; + --shell-settingsHeaderGap: 24.0px; + --shell-settingsRowTop: 10.0px; + --shell-settingsRowPadding: 30.0px; + --shell-settingsRowGap: 24.0px; + --shell-settingsIcon: 36.0px; + --shell-settingsPillHeight: 40.0px; + --shell-settingsPillStroke: 2.0px; + --shell-settingsPillBorder: #ffffff38; + --shell-settingsPillFocusedBorder: #0b0b1066; + --shell-settingsPillPadding: 16.0px; + --shell-settingsPillGap: 12.0px; + --shell-settingsChevron: 30.0px; + --shell-settingsEndPadding: 620.0px; + --shell-panelPaddingX: 30.0px; + --shell-panelPaddingY: 28.0px; + --shell-panelGap: 10.0px; + --shell-panelHeadingGap: 14.0px; + --shell-panelIcon: 36.0px; + --shell-skeletonRadius: 10.0px; +} diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json new file mode 100644 index 00000000..95fa62f2 --- /dev/null +++ b/wolfskin-design/tokens.json @@ -0,0 +1,1592 @@ +{ + "$description": "Wolfskin TV design tokens, extracted by script from the locked prototype (data/tv-v2/opus-final-2026-11-05/index.html, sha256 ee8844fc...). Values are CSS px on the 1920x1080 stage; the box renders 1920x1080 at 320 dpi = density 2.0, so dp/sp = px / 2. Format follows the W3C design-tokens draft ($type/$value); extra keys: px, dp|sp, source (page file:line), usedBy, page (the page value where q1 consolidated it).", + "meta": { + "density": 2.0, + "stagePx": [ + 1920, + 1080 + ], + "stageDp": [ + 960, + 540 + ], + "source": "data/tv-v2/opus-final-2026-11-05/index.html", + "sourceSha256": "ee8844fc6d0237a3727de0d9454db8e5cf3bb14083fb7877d7bb0f4233c6e23d", + "generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py" + }, + "color": { + "bg": { + "$type": "color", + "$value": "#07070a", + "css": "#07070a", + "source": "index.html:10", + "usedBy": "app background; stage; focus-ring gap colour; hero/detail scrim end; nav band when solid" + }, + "surface": { + "$type": "color", + "$value": "#13131a", + "css": "#13131a", + "source": "index.html:10", + "usedBy": "sheets (side panels), state panels, See all card, skeleton blocks, Try again/Show more tail cards" + }, + "surface2": { + "$type": "color", + "$value": "#1b1b25", + "css": "#1b1b25", + "source": "index.html:10", + "usedBy": "card art placeholder behind images; menus; toast; person-page portrait placeholder" + }, + "surface3": { + "$type": "color", + "$value": "#252532", + "css": "#252532", + "source": "index.html:10", + "usedBy": "hero radial placeholder; Coming-in thumbnail placeholder" + }, + "fg": { + "$type": "color", + "$value": "#f4f4f6", + "css": "#f4f4f6", + "source": "index.html:11", + "usedBy": "primary text; primary button fill; focused nav blob; focused chip/row/list fill (inverse focus); white focus ring on avatars and the gear" + }, + "muted": { + "$type": "color", + "$value": "#f4f4f6bd", + "css": "rgb(244 244 246 / .74)", + "source": "index.html:11", + "usedBy": "secondary text: facts lines, overviews, captions under cards, unfocused nav labels, row descriptions" + }, + "dim": { + "$type": "color", + "$value": "#f4f4f68f", + "css": "rgb(244 244 246 / .56)", + "source": "index.html:11", + "usedBy": "tertiary text: sheet labels, small print, pill text, the resting gear" + }, + "border": { + "$type": "color", + "$value": "#ffffff1a", + "css": "rgb(255 255 255 / .10)", + "source": "index.html:11", + "usedBy": "hairline borders on See all/tail cards and state panels" + }, + "accent": { + "$type": "color", + "$value": "#cc9fff", + "css": "oklch(78% 0.14 305)", + "source": "index.html:12", + "usedBy": "lilac focus ring on cards and buttons; progress fills; current-destination underline; See all glyph; requested badge ring; Downloading/Almost ready stage words" + }, + "accentSoft": { + "$type": "color", + "$value": "#2c1a3f", + "css": "oklch(26% 0.07 305)", + "source": "index.html:12", + "usedBy": "selected chip fill (chip.on)" + }, + "accentText": { + "$type": "color", + "$value": "#e4cbff", + "css": "oklch(88% 0.08 305)", + "source": "index.html:12", + "usedBy": "kicker line above hero titles; d-line on title pages; availability words in captions; selected chip text; wordmark \"skin\"" + }, + "ok": { + "$type": "color", + "$value": "#89da9b", + "css": "oklch(82% 0.12 150)", + "source": "index.html:13", + "usedBy": "held badge (green check); Ready stage word; readiness Done" + }, + "warn": { + "$type": "color", + "$value": "#f4c26a", + "css": "oklch(84% 0.12 80)", + "source": "index.html:13", + "usedBy": "Saved HH:MM chip; warning state titles and icons; toast icon; Try again tail glyph" + }, + "fail": { + "$type": "color", + "$value": "#f97770", + "css": "oklch(72% 0.16 25)", + "source": "index.html:13", + "usedBy": "failed-request badge ring; Failed stage word; Refused receipt title" + }, + "onFocus": { + "$type": "color", + "$value": "#0b0b10", + "css": "#0b0b10", + "source": "index.html:84", + "usedBy": "text and icons on any white focus fill: focused nav item, primary button label, focused chip/row/list item, avatar letters" + }, + "buttonSecondary": { + "$type": "color", + "$value": "#ffffff24", + "css": "rgb(255 255 255 / .14)", + "source": "index.html:107", + "usedBy": "secondary (non-primary) pill button at rest on hero rows" + }, + "buttonSecondaryFocused": { + "$type": "color", + "$value": "#ffffff42", + "css": "rgb(255 255 255 / .26)", + "source": "index.html:111", + "usedBy": "secondary pill button while focused (plus the accent ring)" + }, + "titleButtonSecondary": { + "$type": "color", + "$value": "#2c2c38c7", + "css": "rgb(44 44 56 / .78)", + "source": "index.html:279", + "usedBy": "title-page secondary and icon buttons at rest (dark backing so labels read over bright art)" + }, + "titleButtonSecondaryFocused": { + "$type": "color", + "$value": "#464654e6", + "css": "rgb(70 70 84 / .9)", + "source": "index.html:280", + "usedBy": "title-page secondary and icon buttons while focused" + }, + "chipRest": { + "$type": "color", + "$value": "#ffffff1a", + "css": "rgb(255 255 255 / .10)", + "source": "index.html:185", + "usedBy": "filter/sort/season chips at rest" + }, + "navBlobRest": { + "$type": "color", + "$value": "#ffffff1a", + "css": "rgb(255 255 255 / .10)", + "source": "index.html:76", + "usedBy": "nav indicator resting behind the current destination while focus is elsewhere" + }, + "listRowRest": { + "$type": "color", + "$value": "#ffffff0d", + "css": "rgb(255 255 255 / .05)", + "source": "index.html:231", + "usedBy": "sheet rows (person, version, track) at rest; Settings rows (.srow) use the same value" + }, + "sheetScrim": { + "$type": "color", + "$value": "#0000009e", + "css": "rgb(0 0 0 / .62)", + "source": "index.html:226", + "usedBy": "full-screen scrim behind every side sheet" + }, + "badgeRest": { + "$type": "color", + "$value": "#07070abf", + "css": "rgb(7 7 10 / .75)", + "source": "index.html:141", + "usedBy": "badge disc behind the requested/failed glyph" + }, + "badgeWatched": { + "$type": "color", + "$value": "#f4f4f6d9", + "css": "rgb(244 244 246 / .85)", + "source": "index.html:145", + "usedBy": "watched badge disc (defined in CSS; no surface draws it in the locked page) [unused]" + }, + "progressTrack": { + "$type": "color", + "$value": "#ffffff38", + "css": "rgb(255 255 255 / .22)", + "source": "index.html:138", + "usedBy": "progress-bar track on wide cards; title-page progress track uses rgb(255 255 255 / .2) (.d-prog i)" + }, + "railEmpty": { + "$type": "color", + "$value": "#f4f4f638", + "css": "rgb(244 244 246 / .22)", + "source": "index.html:206", + "usedBy": "letter rail: letters with no titles (rail shown only at >= 200 titles)" + } + }, + "personColor": { + "justin": { + "$type": "color", + "$value": "#62c4ff", + "initial": "J", + "source": "fixture/fixture.json profiles[].color", + "usedBy": "Justin: avatar fill in Who's watching (220 px), nav avatar (64 px), person rows in sheets (60 px), library/settings headers (52/56 px); letter in color.onFocus" + }, + "mindel": { + "$type": "color", + "$value": "#ff9d72", + "initial": "M", + "source": "fixture/fixture.json profiles[].color", + "usedBy": "Mindel: avatar fill in Who's watching (220 px), nav avatar (64 px), person rows in sheets (60 px), library/settings headers (52/56 px); letter in color.onFocus" + }, + "shared": { + "$type": "color", + "$value": "#9be38f", + "initial": "S", + "source": "fixture/fixture.json profiles[].color", + "usedBy": "Shared: avatar fill in Who's watching (220 px), nav avatar (64 px), person rows in sheets (60 px), library/settings headers (52/56 px); letter in color.onFocus" + } + }, + "gradient": { + "heroScrim": { + "$type": "gradient", + "$value": "linear-gradient(90deg,rgb(7 7 10 / .92) 0%,rgb(7 7 10 / .55) 42%,rgb(7 7 10 / 0) 70%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .6) 26%,rgb(7 7 10 / 0) 48%)", + "source": "index.html:99", + "usedBy": "Home hero and Discover picks hero: left-to-right darkening (92% at 0, 55% at 42%, 0 at 70%) plus bottom fade into bg (0..48%)" + }, + "detailScrim": { + "$type": "gradient", + "$value": "linear-gradient(90deg,rgb(7 7 10 / .94) 0%,rgb(7 7 10 / .6) 46%,rgb(7 7 10 / .05) 75%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .35) 34%,rgb(7 7 10 / 0) 55%)", + "source": "index.html:210", + "usedBy": "title page backdrop scrim (94% / 60% at 46% / 5% at 75%; bottom fade to 55%)" + }, + "navBand": { + "$type": "gradient", + "$value": "linear-gradient(180deg,rgb(7 7 10 / .72),rgb(7 7 10 / 0))", + "source": "index.html:71", + "usedBy": "nav band at scrollTop <= 40: 72% bg fading to 0 over 150 px" + }, + "navBandSolid": { + "$type": "gradient", + "$value": "linear-gradient(180deg,var(--bg) 0%,var(--bg) 84%,rgb(7 7 10 / 0) 100%)", + "source": "index.html:72", + "usedBy": "nav band once the frame scrolled > 40 px: solid bg to 84% of 150 px, then fades" + }, + "ratingPlate": { + "$type": "gradient", + "$value": "linear-gradient(180deg,rgb(7 7 10 / .9) 0%,rgb(7 7 10 / .72) 52%,rgb(7 7 10 / 0) 100%)", + "source": "index.html:162", + "usedBy": "rating plate on the focused card: 90% bg at top, 72% at 52%, 0 at bottom" + }, + "tileOverlay": { + "$type": "gradient", + "$value": "linear-gradient(0deg,rgb(7 7 10 / .92),rgb(7 7 10 / 0))", + "source": "index.html:135", + "usedBy": "bottom gradient under tile/wide titles" + }, + "fallbackArt": { + "$type": "gradient", + "$value": "linear-gradient(160deg,hsl(var(--h,260) 22% 22%),hsl(var(--h,260) 26% 10%))", + "source": "index.html:130", + "usedBy": "no-art card: 160deg two-tone gradient, hue = hash(title) (hueOf, index.html:744)" + }, + "entryTile": { + "$type": "gradient", + "$value": "linear-gradient(150deg,hsl(var(--h) 30% 26%),hsl(var(--h) 30% 12%))", + "source": "index.html:149", + "usedBy": "service entry tiles: 150deg gradient, hue per service (SERVICE_HUE, index.html:663)" + }, + "initialsCircle": { + "$type": "gradient", + "$value": "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))", + "source": "index.html:288", + "usedBy": "cast circle / person portrait without a photo: initials on a 160deg tinted gradient, hue = hash(name)" + }, + "heroAmbientFilter": { + "$type": "filter", + "$value": "blur(60px) saturate(1.2) brightness(.55)", + "inset": "-80px", + "scale": 1.1, + "source": "index.html:96", + "usedBy": "poster-only hero/title art: blurred wash of the poster behind the one-sheet (Compose: RenderEffect blur 30 dp, saturation 1.2, brightness .55) [INFERENCE: Compose mapping]" + }, + "cardAmbientFilter": { + "$type": "filter", + "$value": "blur(26px) brightness(.5)", + "inset": "-30px", + "source": "index.html:129", + "usedBy": "poster-only art in a landscape card: blurred wash behind the one-sheet" + } + }, + "font": { + "family": { + "$type": "fontFamily", + "$value": "system-ui,-apple-system,\"Segoe UI\",Roboto,\"Noto Sans\",Ubuntu,Cantarell,sans-serif", + "source": "index.html:14", + "usedBy": "all stage text", + "note": "On the box this resolves to the platform sans (Roboto) [INFERENCE]; Compose uses the default FontFamily." + }, + "tabularNumbers": { + "$value": "font-variant-numeric: tabular-nums", + "source": "index.html:156", + "usedBy": "every ratings row" + } + }, + "type": { + "displayDetail": { + "$type": "typography", + "px": 88.0, + "sp": 44.0, + "source": "index.html:213", + "usedBy": "title page title (film, series, episode = series name); clamp 2 lines", + "weight": 800, + "weightSource": "index.html:103", + "lineHeight": 1.02, + "lineHeightSource": "index.html:103", + "letterSpacingEm": -0.02, + "$value": { + "fontSize": "44sp", + "fontWeight": 800 + } + }, + "displayHero": { + "$type": "typography", + "px": 84.0, + "sp": 42.0, + "source": "index.html:103", + "usedBy": "Home hero and Discover picks hero title; clamp 2 lines", + "weight": 800, + "weightSource": "index.html:103", + "lineHeight": 1.02, + "lineHeightSource": "index.html:103", + "letterSpacingEm": -0.02, + "$value": { + "fontSize": "42sp", + "fontWeight": 800 + } + }, + "screenTitle": { + "$type": "typography", + "px": 64.0, + "sp": 32.0, + "source": "index.html:200", + "usedBy": "screen headers: library (Movies/Shows/Anime), See all, service page, Settings, Discover-without-hero; also \"Who's watching?\" (.picker h1) and person name (.p-head h1)", + "weight": 800, + "weightSource": "index.html:200", + "letterSpacingEm": -0.02, + "$value": { + "fontSize": "32sp", + "fontWeight": 800 + } + }, + "sectionTitle": { + "$type": "typography", + "px": 56.0, + "sp": 28.0, + "source": "index.html:181", + "usedBy": "the \"Discover\" section header inside Home; player hand-off title (.player h2)", + "weight": 800, + "weightSource": "index.html:181", + "letterSpacingEm": -0.02, + "$value": { + "fontSize": "28sp", + "fontWeight": 800 + } + }, + "sheetTitle": { + "$type": "typography", + "px": 44.0, + "sp": 22.0, + "source": "index.html:228", + "usedBy": "side-sheet titles (Request, Version, Coming in, ...)", + "weight": 800, + "weightSource": "index.html:228", + "letterSpacingEm": -0.01, + "$value": { + "fontSize": "22sp", + "fontWeight": 800 + } + }, + "wordmark": { + "$type": "typography", + "px": 36.0, + "sp": 18.0, + "source": "index.html:79", + "usedBy": "the \"Wolfskin\" wordmark at the left of the nav and in the picker", + "weight": 800, + "weightSource": "index.html:79", + "letterSpacingEm": -0.02, + "$value": { + "fontSize": "18sp", + "fontWeight": 800 + } + }, + "stateTitle": { + "$type": "typography", + "px": 36.0, + "sp": 18.0, + "source": "index.html:195", + "usedBy": "state panel titles and receipt titles (.receipt h3)", + "$value": { + "fontSize": "18sp", + "fontWeight": 400 + } + }, + "rowHeading": { + "$type": "typography", + "px": 34.0, + "sp": 17.0, + "source": "index.html:119", + "usedBy": "row headings above every shelf and grid section", + "weight": 700, + "weightSource": "index.html:119", + "letterSpacingEm": -0.01, + "$value": { + "fontSize": "17sp", + "fontWeight": 700 + } + }, + "pickerName": { + "$type": "typography", + "px": 34.0, + "sp": 17.0, + "source": "index.html:260", + "usedBy": "names under avatars in Who's watching", + "weight": 650, + "weightSource": "index.html:260", + "$value": { + "fontSize": "17sp", + "fontWeight": 650 + } + }, + "nav": { + "$type": "typography", + "px": 30.0, + "sp": 15.0, + "source": "index.html:81", + "usedBy": "nav destination labels", + "weight": 600, + "weightSource": "index.html:81", + "$value": { + "fontSize": "15sp", + "fontWeight": 600 + } + }, + "listTitle": { + "$type": "typography", + "px": 30.0, + "sp": 15.0, + "source": "index.html:231", + "usedBy": "sheet row names; Settings row titles (.srow .tx b); Coming-in row titles (.crow .tx b); See all label; service entry wordmark", + "weight": 650, + "weightSource": "index.html:231", + "$value": { + "fontSize": "15sp", + "fontWeight": 650 + } + }, + "body": { + "$type": "typography", + "px": 28.0, + "sp": 14.0, + "source": "index.html:104", + "usedBy": "facts lines (h-meta), overviews (h-ov, d-ov), card caption title (.cap b), tile titles (.card .ov b), buttons (.btn), state text, receipts, toast, rating numbers and marks", + "$value": { + "fontSize": "14sp", + "fontWeight": 400 + } + }, + "button": { + "$type": "typography", + "px": 28.0, + "sp": 14.0, + "source": "index.html:107", + "usedBy": "pill buttons (primary and secondary)", + "weight": 700, + "weightSource": "index.html:107", + "$value": { + "fontSize": "14sp", + "fontWeight": 700 + } + }, + "label": { + "$type": "typography", + "px": 26.0, + "sp": 13.0, + "source": "index.html:185", + "usedBy": "chips; kicker (26/650); d-line; d-prog; icon-button caption (.bcap); cast name (.pnm b, 26/700); sheet section labels (.sheet .lab)", + "weight": 650, + "weightSource": "index.html:185", + "$value": { + "fontSize": "13sp", + "fontWeight": 650 + } + }, + "caption": { + "$type": "typography", + "px": 24.0, + "sp": 12.0, + "source": "index.html:154", + "usedBy": "THE FLOOR. Card facts under a focused poster; tile second lines; pills; small print; character names; readiness stages; letter rail; rating-plate floor. Nothing on the stage is smaller.", + "$value": { + "fontSize": "12sp", + "fontWeight": 400 + } + }, + "ratingPlate": { + "$type": "typography", + "px": 28.0, + "sp": 14.0, + "floorPx": 24, + "floorSp": 12.0, + "stepPx": 0.5, + "weight": 700, + "lineHeight": 1.2, + "source": "index.html:164", + "floorSource": "index.html:802", + "$value": { + "fontSize": "14sp", + "min": "12sp", + "fontWeight": 700 + }, + "usedBy": "rating plate on the focused card: starts at 28 px (14 sp, the fork titleSmall), shrinks in 0.5 px steps to the 24 px floor, then drops trailing entries (fitRow, D9)" + }, + "avatarLetter": { + "$type": "typography", + "weight": 800, + "source": "index.html:89", + "sizes": { + "picker": 100, + "nav": 30, + "sheetRow": 28, + "shelfHead": 24, + "chip": 24, + "libHeader": 26, + "sectionHeader": 28 + }, + "usedBy": "avatar letters (px per context)" + }, + "stageLineHeight": { + "$type": "number", + "$value": 1.3, + "source": "index.html:61", + "usedBy": "default line height for all stage text (1.3)" + } + }, + "radius": { + "card": { + "$type": "dimension", + "$value": "8dp", + "px": 16.0, + "dp": 8.0, + "source": "index.html:125", + "usedBy": "every card (poster, tile, wide, entry, See all, tail, skeleton)" + }, + "button": { + "$type": "dimension", + "$value": "19dp", + "px": 38.0, + "dp": 19.0, + "source": "index.html:107", + "usedBy": "pill buttons = height / 2" + }, + "chip": { + "$type": "dimension", + "$value": "17dp", + "px": 34.0, + "dp": 17.0, + "source": "index.html:185", + "usedBy": "chips = height / 2" + }, + "navItem": { + "$type": "dimension", + "$value": "16dp", + "px": 32.0, + "dp": 16.0, + "source": "index.html:81", + "usedBy": "nav item and blob = height / 2" + }, + "listRow": { + "$type": "dimension", + "$value": "12dp", + "px": 24.0, + "dp": 12.0, + "source": "index.html:231", + "usedBy": "sheet rows and Settings rows" + }, + "panel": { + "$type": "dimension", + "$value": "11dp", + "px": 22.0, + "dp": 11.0, + "source": "index.html:194", + "usedBy": "state panels, receipts, Coming-in rows, toast" + }, + "menu": { + "$type": "dimension", + "$value": "13dp", + "px": 26.0, + "dp": 13.0, + "source": "index.html:264", + "usedBy": "menu panel" + }, + "heroSheet": { + "$type": "dimension", + "$value": "9dp", + "px": 18.0, + "dp": 9.0, + "source": "index.html:97", + "usedBy": "upright poster (\"one-sheet\") on poster-only hero/title art" + }, + "cardSheet": { + "$type": "dimension", + "$value": "5dp", + "px": 10.0, + "dp": 5.0, + "source": "index.html:128", + "usedBy": "one-sheet inside a landscape card" + }, + "thumb": { + "$type": "dimension", + "$value": "6dp", + "px": 12.0, + "dp": 6.0, + "source": "index.html:248", + "usedBy": "Coming-in poster thumbnail 96x144" + }, + "pill": { + "$type": "dimension", + "$value": "11dp", + "px": 22.0, + "dp": 11.0, + "source": "index.html:122", + "usedBy": "sample/Saved/Wolfskin/upstream pills" + }, + "progress": { + "$type": "dimension", + "$value": "2dp", + "px": 4.0, + "dp": 2.0, + "source": "index.html:138", + "usedBy": "progress bars" + }, + "circle": { + "$type": "dimension", + "$value": "50%", + "source": "index.html:89", + "usedBy": "avatars, badges, person circles, gear" + } + }, + "layout": { + "safeSide": { + "$type": "dimension", + "$value": "48dp", + "px": 96, + "dp": 48.0, + "source": "index.html:654", + "usedBy": "left/right safe inset on every screen (5% of 1920); first card x, all copy blocks" + }, + "contentWidth": { + "$type": "dimension", + "$value": "864dp", + "px": 1728, + "dp": 864.0, + "source": "index.html:1035", + "usedBy": "content measure 1920 - 2 x 96; slotsOf() = floor((1728 + gap + 8) / (w + gap))" + }, + "safeTopPicker": { + "$type": "dimension", + "$value": "27dp", + "px": 54.0, + "dp": 27.0, + "source": "index.html:258", + "usedBy": "top inset of the wordmark in Who's watching (5% of 1080)" + }, + "navBand": { + "$type": "dimension", + "$value": "75dp", + "px": 150.0, + "dp": 75.0, + "source": "index.html:71", + "usedBy": "nav band height on every non-full-screen screen" + }, + "navPaddingTop": { + "$type": "dimension", + "$value": "22dp", + "px": 44.0, + "dp": 22.0, + "source": "index.html:74", + "usedBy": "nav row top padding; items are vertically centred in 44..150, so a 64 px item spans y 65..129 (measured)" + }, + "navItemHeight": { + "$type": "dimension", + "$value": "32dp", + "px": 64.0, + "dp": 32.0, + "source": "index.html:81", + "usedBy": "nav item / blob height; nav avatar is 64x64" + }, + "navItemPaddingX": { + "$type": "dimension", + "$value": "13dp", + "px": 26.0, + "dp": 13.0, + "source": "index.html:81", + "usedBy": "nav item horizontal padding" + }, + "navItemGap": { + "$type": "dimension", + "$value": "6dp", + "px": 12.0, + "dp": 6.0, + "source": "index.html:74", + "usedBy": "gap between nav items" + }, + "wordmarkGap": { + "$type": "dimension", + "$value": "20dp", + "px": 40.0, + "dp": 20.0, + "source": "index.html:79", + "usedBy": "space after the wordmark before Home" + }, + "navUnderline": { + "$type": "dimension", + "$value": "2dp", + "px": 4.0, + "dp": 2.0, + "source": "index.html:83", + "usedBy": "accent underline under the current destination, inset 26 px each side, 4 px above the item bottom; hidden while that item is focused" + }, + "navBlobAvatarPad": { + "$type": "dimension", + "$value": "4dp", + "px": 8, + "dp": 4.0, + "source": "index.html:1681", + "usedBy": "the blob grows 8 px each side around the avatar (80x80 circle)" + }, + "navSolidAfter": { + "$type": "dimension", + "$value": "20dp", + "px": 40, + "dp": 20.0, + "source": "index.html:1668", + "usedBy": "the nav band switches to the solid gradient once the frame scrollTop > 40" + }, + "pinnedLine": { + "$type": "dimension", + "$value": "84dp", + "px": 168, + "dp": 84.0, + "source": "index.html:654", + "usedBy": "focus-follow: a focused row (not hero) scrolls so its heading top sits 168 px from the top; rows whose bottom fits the first screen (y < 700 and y + h <= 1040) do not scroll" + }, + "windowAhead": { + "$type": "dimension", + "$value": "350dp", + "px": 700, + "dp": 350.0, + "source": "index.html:654", + "usedBy": "prototype windowing distance (rows within 700 px of the viewport exist); in Compose lazy layouts do this" + }, + "rowHeadingBand": { + "$type": "dimension", + "$value": "30dp", + "px": 60, + "dp": 30.0, + "source": "index.html:1018", + "usedBy": "shelf row: heading band (44 px heading at top 0) before the cards start at y + 60" + }, + "rowGapAfter": { + "$type": "dimension", + "$value": "22dp", + "px": 44, + "dp": 22.0, + "source": "index.html:1018", + "usedBy": "shelf row: space after cards (+ caption band) before the next row; shelf row height = 60 + card h + caption + 44" + }, + "gridRowGap": { + "$type": "dimension", + "$value": "14dp", + "px": 28, + "dp": 14.0, + "source": "index.html:1019", + "usedBy": "grid row height = 384 + 76 + 28 = 488 (library, See all, person)" + }, + "row_hero": { + "$type": "dimension", + "$value": "410dp", + "px": 820, + "dp": 410.0, + "source": "index.html:1016", + "usedBy": "Home hero row height (the hero art itself is 1920x1080 behind it)" + }, + "row_phero": { + "$type": "dimension", + "$value": "370dp", + "px": 740, + "dp": 370.0, + "source": "index.html:1017", + "usedBy": "Discover picks-hero row height" + }, + "row_section": { + "$type": "dimension", + "$value": "75dp", + "px": 150, + "dp": 75.0, + "source": "index.html:1020", + "usedBy": "Home \"Discover\" section header row" + }, + "row_chips": { + "$type": "dimension", + "$value": "52dp", + "px": 104, + "dp": 52.0, + "source": "index.html:1021", + "usedBy": "chip row (Discover filters, library toolbar, See all/service type chips, seasons)" + }, + "row_note": { + "$type": "dimension", + "$value": "42dp", + "px": 84, + "dp": 42.0, + "source": "index.html:1022", + "usedBy": "one-line note row (picks unavailable, no seeds, episode note)" + }, + "row_state": { + "$type": "dimension", + "$value": "150dp", + "px": 300, + "dp": 150.0, + "source": "index.html:1023", + "usedBy": "state panel row (calm or warning, with recovery buttons)" + }, + "row_lhead": { + "$type": "dimension", + "$value": "118dp", + "px": 236, + "dp": 118.0, + "source": "index.html:1024", + "usedBy": "screen header row (library, See all, service, Settings, Discover without hero)" + }, + "row_dhead": { + "$type": "dimension", + "$value": "450dp", + "px": 900, + "dp": 450.0, + "source": "index.html:1025", + "usedBy": "title page header row (copy + buttons over the backdrop)" + }, + "row_phead": { + "$type": "dimension", + "$value": "235dp", + "px": 470, + "dp": 235.0, + "source": "index.html:1026", + "usedBy": "person page header row" + }, + "row_gh": { + "$type": "dimension", + "$value": "42dp", + "px": 84, + "dp": 42.0, + "source": "index.html:1027", + "usedBy": "grid section heading row (person page)" + }, + "row_endcta": { + "$type": "dimension", + "$value": "100dp", + "px": 200, + "dp": 100.0, + "source": "index.html:1028", + "usedBy": "Home end action row (\"Explore more in Discover\")" + }, + "row_srow": { + "$type": "dimension", + "$value": "62dp", + "px": 124, + "dp": 62.0, + "source": "index.html:1029", + "usedBy": "Settings list row pitch (row 104 + 20)" + }, + "heroCopyLeft": { + "$type": "dimension", + "$value": "48dp", + "px": 96.0, + "dp": 48.0, + "source": "index.html:100", + "usedBy": "hero copy block x" + }, + "heroCopyBottom": { + "$type": "dimension", + "$value": "55dp", + "px": 110.0, + "dp": 55.0, + "source": "index.html:100", + "usedBy": "hero copy block anchored 110 px above the 1080 art bottom (bottom edge of buttons at y 710 on Home)" + }, + "heroCopyWidth": { + "$type": "dimension", + "$value": "500dp", + "px": 1000.0, + "dp": 500.0, + "source": "index.html:100", + "usedBy": "hero copy block width" + }, + "heroOverviewMax": { + "$type": "dimension", + "$value": "480dp", + "px": 960.0, + "dp": 480.0, + "source": "index.html:105", + "usedBy": "hero overview max width; 2-line clamp" + }, + "detailCopyTop": { + "$type": "dimension", + "$value": "100dp", + "px": 200.0, + "dp": 100.0, + "source": "index.html:212", + "usedBy": "title page copy block top" + }, + "detailCopyWidth": { + "$type": "dimension", + "$value": "530dp", + "px": 1060.0, + "dp": 530.0, + "source": "index.html:212", + "usedBy": "title page copy block width; overview 3-line clamp" + }, + "heroSheet": { + "$type": "dimension", + "px": [ + 420, + 630 + ], + "dp": [ + 210, + 315 + ], + "right": 180, + "top": 150, + "source": "index.html:97", + "usedBy": "one-sheet poster on a poster-only Home hero/title page; Discover picks hero uses 360x540 at top 140 (.row-phero .art-sheet)" + }, + "buttonHeight": { + "$type": "dimension", + "$value": "38dp", + "px": 76.0, + "dp": 38.0, + "source": "index.html:107", + "usedBy": "pill button height (primary, secondary, sheet buttons)" + }, + "buttonPaddingX": { + "$type": "dimension", + "$value": "19dp", + "px": 38.0, + "dp": 19.0, + "source": "index.html:107", + "usedBy": "pill button horizontal padding" + }, + "buttonGap": { + "$type": "dimension", + "$value": "10dp", + "px": 20.0, + "dp": 10.0, + "source": "index.html:106", + "usedBy": "gap between buttons in a row" + }, + "buttonIcon": { + "$type": "dimension", + "$value": "15dp", + "px": 30.0, + "dp": 15.0, + "source": "index.html:108", + "usedBy": "glyph inside a pill button; icon gap 14" + }, + "iconButton": { + "$type": "dimension", + "$value": "38dp", + "px": 76.0, + "dp": 38.0, + "source": "index.html:274", + "usedBy": "round icon button (Restart, Version, Audio and subtitles, Mark watched, Trailer, More); glyph 32" + }, + "iconCaptionLine": { + "$type": "dimension", + "$value": "19dp", + "px": 38.0, + "dp": 19.0, + "source": "index.html:281", + "usedBy": "reserved caption line under a button row that has icon buttons (margin-top 16); shows \"label · note\" of the focused icon button" + }, + "sheetWidth": { + "$type": "dimension", + "$value": "400dp", + "px": 800.0, + "dp": 400.0, + "source": "index.html:227", + "usedBy": "right-side sheet width; padding 80 top/bottom, 64 sides; vertical gap 18" + }, + "sheetRow": { + "$type": "dimension", + "$value": "50dp", + "px": 100.0, + "dp": 50.0, + "source": "index.html:231", + "usedBy": "person row in request sheets (version rows auto height, padding 20/26; track rows 84)" + }, + "settingsRow": { + "$type": "dimension", + "px": [ + 1300, + 104 + ], + "dp": [ + 650, + 52 + ], + "source": "index.html:321", + "usedBy": "Settings list row (icon 36, title 30/700, one-line description 24, pills, chevron)" + }, + "stateWidth": { + "$type": "dimension", + "$value": "550dp", + "px": 1100.0, + "dp": 550.0, + "source": "index.html:194", + "usedBy": "state panel width; padding 34/40; title 36; text 28" + }, + "captionOffset": { + "$type": "dimension", + "px": 18, + "dp": 9, + "source": "index.html:1242", + "usedBy": "caption under a focused poster starts 18 px below the card bottom (capHtml top = headH + h + 18); width 640" + }, + "plateInset": { + "$type": "dimension", + "px": { + "top": 14, + "side": 16, + "bottomFade": 46 + }, + "available": "card width - 32", + "source": "index.html:162, index.html:1234", + "usedBy": "rating plate padding; the plate row must fit width - 32 px" + }, + "badge": { + "$type": "dimension", + "$value": "24dp", + "px": 48.0, + "dp": 24.0, + "source": "index.html:141", + "usedBy": "state badge circle, 14 px from right and bottom; glyph 28" + }, + "badgeInset": { + "$type": "dimension", + "$value": "7dp", + "px": 14.0, + "dp": 7.0, + "source": "index.html:141", + "usedBy": "badge inset from the card corner" + }, + "progressBar": { + "$type": "dimension", + "$value": "4dp", + "px": 8.0, + "dp": 4.0, + "source": "index.html:138", + "usedBy": "progress bar on wide cards (inset 18 left/right, 12 from bottom); title page bar is 320x8" + }, + "pickerAvatar": { + "$type": "dimension", + "$value": "110dp", + "px": 220.0, + "dp": 110.0, + "source": "index.html:261", + "usedBy": "Who's watching avatar; letter 100 px; avatars 90 px apart; name 26 px below" + }, + "pickerGap": { + "$type": "dimension", + "$value": "45dp", + "px": 90.0, + "dp": 45.0, + "source": "index.html:259", + "usedBy": "gap between people in the picker (also between gear slots)" + }, + "pickerStackGap": { + "$type": "dimension", + "$value": "35dp", + "px": 70.0, + "dp": 35.0, + "source": "index.html:256", + "usedBy": "vertical gap between \"Who's watching?\", the people and the gear row (gear row margin-top -34)" + }, + "gear": { + "$type": "dimension", + "$value": "28dp", + "px": 56.0, + "dp": 28.0, + "source": "index.html:306", + "usedBy": "Settings gear under the last person; glyph 30; caption 24 px, 72 px below the gear top, visible only on focus" + }, + "navAvatar": { + "$type": "dimension", + "$value": "32dp", + "px": 64.0, + "dp": 32.0, + "source": "index.html:90", + "usedBy": "avatar at the right end of the nav" + }, + "castCircle": { + "$type": "dimension", + "$value": "100dp", + "px": 200, + "dp": 100.0, + "source": "index.html:285", + "usedBy": "cast circle; name 26/700 at 216 px; character 24 muted; card 290 tall" + }, + "personPortrait": { + "$type": "dimension", + "$value": "120dp", + "px": 240.0, + "dp": 120.0, + "source": "index.html:294", + "usedBy": "person page portrait circle; header at x 96, y 170, gap 48" + }, + "thumb": { + "$type": "dimension", + "px": [ + 96, + 144 + ], + "dp": [ + 48, + 72 + ], + "source": "index.html:248", + "usedBy": "Coming-in poster thumbnail" + }, + "toast": { + "$type": "dimension", + "px": { + "right": 96, + "top": 150, + "maxWidth": 760 + }, + "source": "index.html:270", + "usedBy": "toast position (top right under the nav)" + }, + "letterRail": { + "$type": "dimension", + "px": { + "width": 52, + "letterHeight": 30, + "focusedLetterHeight": 44, + "right": 96, + "top": 150 + }, + "source": "index.html:204", + "usedBy": "letter rail (only for destinations with >= 200 titles)" + } + }, + "card": { + "poster": { + "$type": "card", + "px": { + "w": 256, + "h": 384, + "gap": 24, + "pitch": 280, + "captionBand": 76 + }, + "dp": { + "w": 128.0, + "h": 192.0, + "gap": 12.0, + "pitch": 140.0, + "captionBand": 38.0 + }, + "ratio": "2:3", + "fullSlotsPerRow": 6, + "source": "index.html:656", + "usedBy": "every title shelf, library grid, See all grid, More like this, person filmography; art only at rest" + }, + "tile": { + "$type": "card", + "px": { + "w": 416, + "h": 234, + "gap": 24, + "pitch": 440, + "captionBand": 0 + }, + "dp": { + "w": 208.0, + "h": 117.0, + "gap": 12.0, + "pitch": 220.0, + "captionBand": 0.0 + }, + "ratio": "16:9", + "fullSlotsPerRow": 4, + "source": "index.html:657", + "usedBy": "Next up, New in your library, season episodes; title + second line on a bottom gradient" + }, + "wide": { + "$type": "card", + "px": { + "w": 560, + "h": 315, + "gap": 24, + "pitch": 584, + "captionBand": 0 + }, + "dp": { + "w": 280.0, + "h": 157.5, + "gap": 12.0, + "pitch": 292.0, + "captionBand": 0.0 + }, + "ratio": "16:9", + "fullSlotsPerRow": 3, + "source": "index.html:658", + "usedBy": "Continue watching (only when a person has more than one resume item; the first resume is the hero)" + }, + "entry": { + "$type": "card", + "px": { + "w": 256, + "h": 144, + "gap": 24, + "pitch": 280, + "captionBand": 0 + }, + "dp": { + "w": 128.0, + "h": 72.0, + "gap": 12.0, + "pitch": 140.0, + "captionBand": 0.0 + }, + "ratio": "16:9", + "fullSlotsPerRow": 6, + "source": "index.html:659", + "usedBy": "Discover Services row (text wordmark on a tinted gradient; no third-party logos)" + }, + "person": { + "$type": "card", + "px": { + "w": 200, + "h": 290, + "gap": 28, + "pitch": 228, + "captionBand": 0 + }, + "dp": { + "w": 100.0, + "h": 145.0, + "gap": 14.0, + "pitch": 114.0, + "captionBand": 0.0 + }, + "ratio": "1:1 circle", + "fullSlotsPerRow": 7, + "source": "index.html:660", + "usedBy": "Cast row: 200 px circle + name + character, 290 px tall" + } + }, + "focus": { + "scaleCard": { + "$type": "number", + "$value": 1.08, + "source": "index.html:134", + "page": { + ".card.f": 1.08, + ".pp.f (picker person)": 1.08, + ".pgear.f": 1.08, + "person circle": 1.08 + }, + "usedBy": "cards of every kind, cast circles, picker people, the gear" + }, + "scaleControl": { + "$type": "number", + "$value": 1.05, + "source": "index.html:189", + "page": { + ".btn.f": 1.06, + ".chip.f": 1.05 + }, + "usedBy": "pill and icon buttons, chips", + "note": "q1 = 3 scales, settled by Justin 2026-11-05 (corr=c248698d8546e1ab): buttons 1.05. The page button scale 1.06 folds into 1.05 (delta 0.01 = 2 px on a 200 px button). Spec 4.5, 12.4 #1." + }, + "scaleRow": { + "$type": "number", + "$value": 1.02, + "source": "index.html:327", + "page": { + ".prow.f": 1.03, + ".crow.f": 1.02, + ".srow.f": 1.02 + }, + "usedBy": "full-width list rows: sheet rows, Coming-in rows, Settings rows", + "note": "q1, settled by Justin 2026-11-05 (corr=c248698d8546e1ab): sheet rows 1.02. The page sheet-row scale 1.03 folds into 1.02. Spec 4.5, 12.4 #1." + }, + "ringCard": { + "$type": "shadow", + "$value": "0 0 0 4px var(--bg),0 0 0 8px var(--accent),0 18px 50px rgb(0 0 0 / .65)", + "gapPx": 4, + "ringPx": 4, + "gapDp": 2, + "ringDp": 2, + "gapColor": "color.bg", + "ringColor": "color.accent", + "liftShadow": "0 18px 50px rgb(0 0 0 / .65)", + "source": "index.html:134", + "usedBy": "focused card: a 4 px bg-coloured gap then a 4 px lilac ring OUTSIDE the art (never covers it), plus a lift shadow; drawn on the circle for cast cards" + }, + "ringButton": { + "$type": "shadow", + "$value": "0 0 0 4px var(--bg),0 0 0 8px var(--accent),0 12px 40px rgb(0 0 0 / .5)", + "gapPx": 4, + "ringPx": 4, + "ringColor": "color.accent", + "liftShadow": "0 12px 40px rgb(0 0 0 / .5)", + "source": "index.html:110", + "usedBy": "focused pill and icon buttons (primary stays white, secondary lightens to buttonSecondaryFocused)" + }, + "ringAvatar": { + "$type": "shadow", + "$value": "0 0 0 4px var(--bg),0 0 0 8px var(--fg)", + "gapPx": 4, + "ringPx": 4, + "ringColor": "color.fg", + "source": "index.html:92", + "usedBy": "focused nav avatar and focused gear (white ring, not lilac)" + }, + "ringPicker": { + "$type": "shadow", + "$value": "0 0 0 6px var(--bg),0 0 0 12px var(--fg)", + "gapPx": 6, + "ringPx": 6, + "ringColor": "color.fg", + "source": "index.html:263", + "usedBy": "focused person in Who's watching (white 6 + 6 ring; name turns fg)" + }, + "fillInverse": { + "$type": "rule", + "$value": "background color.fg, text color.onFocus", + "source": "index.html:189, index.html:237, index.html:327, index.html:253", + "usedBy": "focused chip, sheet row, Settings row, Coming-in row and nav blob: white fill with dark text (no ring)" + } + }, + "motion": { + "fast": { + "$type": "duration", + "$value": "140ms", + "easing": "ease = cubic-bezier(.25,.1,.25,1)", + "source": "index.html:125", + "page": { + ".card/.btn/.chip/.pp/.prow/.crow/.srow/.pgear transform": "140ms", + ".cap opacity (caption fade-in)": "120ms", + ".dots svg opacity": "140ms" + }, + "usedBy": "focus scale + ring in and out on every focusable; caption fade under a focused poster (page 120 ms folds in)", + "note": "q1, settled by Justin 2026-11-05 (corr=c248698d8546e1ab): the caption fade is 140 ms. Three durations in total (140, 160, 180) plus the blob spring." + }, + "base": { + "$type": "duration", + "$value": "160ms", + "easing": "ease", + "source": "index.html:162", + "page": { + ".rplate opacity": "160ms", + "#nav background": "160ms", + ".track transform (row slide)": "160ms", + ".chips transform": "160ms", + ".dbg .dim opacity": "160ms", + ".toast opacity": "160ms" + }, + "usedBy": "rating plate fade, nav band solid/transparent, row track slide by one card, chip strip shift, title-page dim as it scrolls" + }, + "scroll": { + "$type": "duration", + "$value": "180ms", + "easing": "ease-out cubic: 1 - (1 - t)^3 = cubic-bezier(.33,1,.68,1)", + "source": "index.html:1076", + "usedBy": "vertical frame scroll to the pinned line (Compose BringIntoViewSpec animation spec)" + }, + "navBlob": { + "$type": "transition", + "$value": { + "type": "spring", + "dampingRatio": 1.0, + "stiffness": 650 + }, + "page": { + "transition": "transform .2s ease-out,width .2s ease-out,height .2s ease-out,background-color .2s ease-out", + "measured": "x 478 -> 688 px: 557.4 at 50 ms, 621.8 at 99 ms (68.5% of travel), 668.4 at 150 ms, at rest by 200 ms; width 198 -> 171 animates with it" + }, + "source": "index.html:76", + "usedBy": "nav indicator: x, width, height and colour (white when the nav has focus, rest colour behind the current destination when not); label colour follows (page .ni color .2s ease-out)", + "verified": { + "script": "data/tv-spec/spring_check.py", + "method": "mass 1, x'' = -k(x-1) - 2*zeta*sqrt(k)*x', integrated at 0.01 ms steps; k searched 400-1000 step 10 for least error against the page measurements", + "pxAt50_99_150_200ms": [ + 554.5, + 628.7, + 665.9, + 680.2 + ], + "pageMeasuredPx": [ + 557.4, + 621.8, + 668.4, + 688.0 + ], + "reaches95pctMs": 186, + "reaches99pctMs": 260, + "within1pxMs": 294, + "maxOvershoot": 0, + "previousStiffness": "530 (revision 1, reached 66.4% at 99 ms against the page 68.5%)" + }, + "note": "q1 = spring, settled by Justin 2026-11-05 (corr=c248698d8546e1ab): a no-bounce spring matched to the measured 200 ms ease-out. Damping ratio 1.0 is critically damped, so it cannot overshoot. Stiffness 650 is the best fit to the measured curve (revision 1 had 530, an untested derivation). Visually settled (95%) by 186 ms; the last pixel arrives at about 300 ms. Snap with no animation when the system animator scale is 0." + }, + "ratingDwell": { + "$type": "duration", + "$value": "350ms", + "source": "index.html:860", + "usedBy": "a focused card requests ratings only after 350 ms of dwell; moving on cancels; cached ratings show at once (fork DEBOUNCE_MILLIS 350)" + }, + "toast": { + "$type": "duration", + "$value": "3200ms", + "source": "index.html:1733", + "usedBy": "toast visible time" + }, + "reducedMotion": { + "$type": "rule", + "$value": "all transitions instant", + "source": "index.html:330", + "usedBy": "when Settings.Global ANIMATOR_DURATION_SCALE == 0: no scale animation, no scroll animation, blob snaps" + } + }, + "image": { + "posterRequest": { + "px": [ + 256, + 384 + ], + "decode": "RGB565", + "source": "index.html:548 (memory table), answers p3", + "usedBy": "poster cards; decode at min(source, slot); fixture sources are 300x450" + }, + "tileRequest": { + "px": [ + 416, + 234 + ], + "decode": "ARGB_8888", + "source": "index.html:549", + "usedBy": "tile cards" + }, + "wideRequest": { + "px": [ + 560, + 315 + ], + "decode": "ARGB_8888", + "source": "index.html:401", + "usedBy": "Continue watching cards" + }, + "heroRequest": { + "px": [ + 1280, + 720 + ], + "decode": "ARGB_8888", + "bytes": 3686400, + "source": "index.html:549", + "usedBy": "Home hero, Discover hero, title backdrop: the 1280x720 source, never upscaled; released when scrolled past" + }, + "castRequest": { + "px": [ + 200, + 200 + ], + "source": "index.html:550", + "usedBy": "cast portraits: decode min(source 185x278, slot)" + }, + "crossfade": { + "$value": false, + "source": "index.html:536; answers p3", + "usedBy": "no crossfade on cards (Coil crossfade off)" + }, + "memoryCacheMB": { + "$value": 64, + "source": "index.html:552; answers p3", + "usedBy": "Coil memory cache cap (not a heap percentage)" + }, + "diskCacheMB": { + "$value": 128, + "source": "index.html:553", + "usedBy": "Coil disk cache" + }, + "concurrentReads": { + "$value": { + "catalogue": 2, + "image": 4 + }, + "source": "index.html:489 (Rules row)", + "usedBy": "at most 2 catalogue reads and 4 image reads at once" + } + }, + "behaviour": { + "rowLimit": { + "$value": 20, + "source": "index.html:666", + "usedBy": "cards per shelf before See all (D3)" + }, + "letterRailMin": { + "$value": 200, + "source": "index.html:667", + "usedBy": "the letter rail exists only for a destination of >= 200 titles, sort A-Z (n4)" + }, + "gridColumns": { + "$value": 6, + "source": "index.html:1425", + "usedBy": "library, See all and person grids" + }, + "libraryChunk": { + "$value": 200, + "source": "index.html:469", + "usedBy": "library read in 200-item chunks ahead of focus" + }, + "laneLoadRows": { + "$value": 2, + "source": "index.html:472", + "usedBy": "catalogue lane is read when within 2 rows of focus" + }, + "nextPageCards": { + "$value": 10, + "source": "index.html:472", + "usedBy": "catalogue page 2+ is read when focus is 10 cards from the row end" + }, + "heroSlides": { + "$value": 5, + "source": "index.html:883", + "usedBy": "Discover picks hero slides" + }, + "cacheRatingsHours": { + "$value": 6, + "entries": 300, + "source": "index.html:473", + "usedBy": "ratings cache per (type, tmdb) and per (series, season)" + }, + "laneCacheMinutes": { + "$value": 30, + "source": "index.html:472", + "usedBy": "last-known-good per catalogue lane" + }, + "libraryRefreshMinutes": { + "$value": 10, + "source": "index.html:469", + "usedBy": "library snapshot refreshed on open if older than 10 min" + }, + "requestsPollSeconds": { + "$value": 30, + "source": "index.html:479", + "usedBy": "Requests (Coming in) poll while a request is unsettled or the sheet is open" + }, + "comingInVisibleRows": { + "$value": 5, + "expanded": 4, + "source": "index.html:1907", + "usedBy": "Coming-in sheet shows 5 rows (4 while one is expanded), window follows focus" + } + } +} \ No newline at end of file diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 new file mode 100644 index 00000000..128b08d1 --- /dev/null +++ b/wolfskin-design/tokens.sha256 @@ -0,0 +1,5 @@ +1438f7ca4a2b6f951c9e643ab0afacbf2f7fc98e57905295d518c6225e054de4 generate_tokens.py +408b7c3f4db2028deda97ac0570a57a4da4877917fd0980b2bfa5afe8f8fa790 shell-tokens.json +1037c46c9d804f9349e927257de13ba32cbd1c05e208ec0bc1d345f4b465f1eb src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +c133085ef60612df9753ef3681446d9ccb2eb29b1ee1955b86254714c67525e3 tokens.css +5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json