diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 30bff204..583febe8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,3 @@ -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 @@ -6,7 +5,6 @@ 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 @@ -196,7 +194,6 @@ configure { sourceSets { getByName("main") { kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" - kotlin.directories += "src/wolfskin/java" } } @@ -227,47 +224,6 @@ 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) } - } } } @@ -332,7 +288,6 @@ tasks.named("preBuild") { } dependencies { - implementation(project(":wolfskin-design")) implementation(project(":conductarr-client")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) @@ -452,18 +407,8 @@ 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 deleted file mode 100644 index d99323a9..00000000 --- a/app/src/debug/assets/wolfskin-shell.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "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 deleted file mode 100644 index ec3f55e3..00000000 --- a/app/src/default/res/mipmap-anydpi-v26/ic_banner.xml +++ /dev/null @@ -1,4 +0,0 @@ - - diff --git a/app/src/default/res/mipmap-hdpi/ic_launcher.webp b/app/src/default/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index 4ac23262..00000000 Binary files a/app/src/default/res/mipmap-hdpi/ic_launcher.webp and /dev/null 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 deleted file mode 100644 index de1628cc..00000000 Binary files a/app/src/default/res/mipmap-hdpi/ic_launcher_foreground.webp and /dev/null 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 deleted file mode 100644 index df5faba9..00000000 Binary files a/app/src/default/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-mdpi/ic_launcher.webp b/app/src/default/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 415cf194..00000000 Binary files a/app/src/default/res/mipmap-mdpi/ic_launcher.webp and /dev/null 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 deleted file mode 100644 index d9df942a..00000000 Binary files a/app/src/default/res/mipmap-mdpi/ic_launcher_foreground.webp and /dev/null 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 deleted file mode 100644 index dea8ff64..00000000 Binary files a/app/src/default/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_banner.png b/app/src/default/res/mipmap-xhdpi/ic_banner.png deleted file mode 100644 index ee42b91e..00000000 Binary files a/app/src/default/res/mipmap-xhdpi/ic_banner.png and /dev/null 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 deleted file mode 100644 index ee42b91e..00000000 Binary files a/app/src/default/res/mipmap-xhdpi/ic_banner_foreground.png and /dev/null differ diff --git a/app/src/default/res/mipmap-xhdpi/ic_launcher.webp b/app/src/default/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 924b40eb..00000000 Binary files a/app/src/default/res/mipmap-xhdpi/ic_launcher.webp and /dev/null 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 deleted file mode 100644 index e218f8bd..00000000 Binary files a/app/src/default/res/mipmap-xhdpi/ic_launcher_foreground.webp and /dev/null 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 deleted file mode 100644 index 40674eff..00000000 Binary files a/app/src/default/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/default/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 6f5be441..00000000 Binary files a/app/src/default/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null 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 deleted file mode 100644 index 8c1935c7..00000000 Binary files a/app/src/default/res/mipmap-xxhdpi/ic_launcher_foreground.webp and /dev/null 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 deleted file mode 100644 index 48a4e286..00000000 Binary files a/app/src/default/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/default/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/default/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index 1cf49fb6..00000000 Binary files a/app/src/default/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null 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 deleted file mode 100644 index afc41a8b..00000000 Binary files a/app/src/default/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and /dev/null 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 deleted file mode 100644 index 122a33e0..00000000 Binary files a/app/src/default/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/default/res/values-hu/strings.xml b/app/src/default/res/values-hu/strings.xml deleted file mode 100644 index fc653df6..00000000 --- a/app/src/default/res/values-hu/strings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - 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 deleted file mode 100644 index e334b3ba..00000000 --- a/app/src/default/res/values-pl/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - O Wolfskin - diff --git a/app/src/default/res/values/strings.xml b/app/src/default/res/values/strings.xml deleted file mode 100644 index dd5ba542..00000000 --- a/app/src/default/res/values/strings.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - 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 ec61583d..a1410d0b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -66,6 +66,7 @@ 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 @@ -233,7 +234,7 @@ class MainActivity : AppCompatActivity() { viewModel.appStart(intent) setContent { - MaterialTheme { + MaterialTheme(colorScheme = WolfskinThemeColors.darkScheme) { 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 fcaa3165..26dda908 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 org.justinvisser.wolfskin.tv.WolfskinApp +import com.github.damontecres.wholphin.ui.nav.ApplicationContent 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) { - WolfskinApp( + ApplicationContent( 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 b6d9426c..63761d99 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.PURPLE, + defaultValue = AppThemeColors.WOLFSKIN, getter = { it.interfacePreferences.appThemeColors }, setter = { prefs, value -> prefs.updateInterfacePreferences { appThemeColors = value } @@ -647,19 +647,6 @@ 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, @@ -1319,7 +1306,6 @@ 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 ce8952a7..17649be9 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,6 +41,7 @@ fun WatchedIconBackground(): Color = when (LocalTheme.current) { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, + AppThemeColors.WOLFSKIN, AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, @@ -55,6 +56,8 @@ 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 9021e213..f2addcd8 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,6 +143,7 @@ fun sliderActiveColor(focused: Boolean): Color { return when (theme) { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, + AppThemeColors.WOLFSKIN, AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, @@ -179,6 +180,7 @@ 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 5bef2675..110cb637 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,9 +35,6 @@ 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 3e449875..63789d6c 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,10 +69,6 @@ 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 100b6caf..c26cb891 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,6 +771,7 @@ 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 daf6fa07..43e213ea 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,6 +70,7 @@ 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 8ae95b25..8cce37e5 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,9 +13,10 @@ 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.PURPLE } + compositionLocalOf { AppThemeColors.WOLFSKIN } fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors = when (appThemeColors) { @@ -27,13 +28,14 @@ fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors = AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors AppThemeColors.RED -> RedThemeColors AppThemeColors.BROWN -> BrownThemeColors - AppThemeColors.UNRECOGNIZED -> PurpleThemeColors + AppThemeColors.WOLFSKIN -> WolfskinThemeColors + AppThemeColors.UNRECOGNIZED -> WolfskinThemeColors } @Composable fun WholphinTheme( darkTheme: Boolean = true, - appThemeColors: AppThemeColors = AppThemeColors.PURPLE, + appThemeColors: AppThemeColors = AppThemeColors.WOLFSKIN, 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 new file mode 100644 index 00000000..ee0a77b5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/WolfskinThemeColors.kt @@ -0,0 +1,59 @@ +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 910853e9..55553166 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; - reserved 8; + WOLFSKIN = 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 4c5f707b..ec3f55e3 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml @@ -1,9 +1,4 @@ - - - - - - + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp index 5b7669dc..4ac23262 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 8e05a03a..de1628cc 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 e6a5972a..df5faba9 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 f44d1093..415cf194 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 9e9214e8..d9df942a 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 3766935e..dea8ff64 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 4fa742db..ee42b91e 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 52b72c47..ee42b91e 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 ce75a196..924b40eb 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 22298ad1..e218f8bd 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 3f7c7e29..40674eff 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 e5cef34d..6f5be441 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 fafcb4f1..8c1935c7 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 5c1a8e36..48a4e286 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 90341396..1cf49fb6 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 dbe5821e..afc41a8b 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 2ef5c7d0..122a33e0 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 1fadeac7..3783bfdf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ - Wholphin - Wholphin + Wolfskin + Wolfskin About Active Recordings @@ -604,8 +604,8 @@ Fill Fill width Fill height - Wholphin Default - Wholphin Compact + Wolfskin Default + Wolfskin Compact Series Thumb images Episode Thumbnail images @@ -637,6 +637,7 @@ @string/black @string/red @string/brown + @string/wolfskin_theme Ignore @@ -830,7 +831,7 @@ Select all Separate types Prefer showing logos for titles - Wholphin updated to %s + Wolfskin 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 0308ae9e..2aca4d36 100644 --- a/app/src/main/res/values/wolfskin.xml +++ b/app/src/main/res/values/wolfskin.xml @@ -1,6 +1,6 @@ - Wolfskin screens + Wolfskin 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 e262e2ad..3ef4ad20 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 org.justinvisser.wolfskin.design.WolfskinColors +import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors import org.junit.Assert.assertTrue import org.junit.Test @@ -10,8 +10,8 @@ import org.junit.Test class WolfskinThemeColorsTest { @Test fun darkPaletteHasReadableNormalAndFocusedText() { - val television = WolfskinColors.darkScheme - val material = WolfskinColors.darkSchemeMaterial + val television = WolfskinThemeColors.darkScheme + val material = WolfskinThemeColors.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 deleted file mode 100644 index dd5ee44c..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/SettingsHandOffCaptureTest.kt +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index d1944584..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellRoutingTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 78690396..00000000 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/UpstreamContentFocusTest.kt +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100644 index 2b7562df..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellPeople.kt +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index e69d1496..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ShellRouting.kt +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 068c029b..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ /dev/null @@ -1,207 +0,0 @@ -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 deleted file mode 100644 index b85b702f..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinScreenPreference.kt +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 50df9f12..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt +++ /dev/null @@ -1,28 +0,0 @@ -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 0ccf48aa..394aa9cd 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 # app default/debug suite plus wolfskin-design JVM/capture suite +make test # upstream testDefaultDebugUnitTest in the same image, no secrets make apk-debug # default debug, no secrets; MPV stub and no extra codec decoders ``` @@ -48,78 +48,6 @@ 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 76b787aa..f2687b17 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,5 +30,4 @@ 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 deleted file mode 100644 index 96fe9988..00000000 --- a/wolfskin-design/build.gradle.kts +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100755 index 63a9707e..00000000 --- a/wolfskin-design/generate_tokens.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/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 deleted file mode 100644 index b24e8e97..00000000 --- a/wolfskin-design/shell-tokens.json +++ /dev/null @@ -1,320 +0,0 @@ -{ - "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 deleted file mode 100644 index 0de442ff..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 14a1cc76..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 82fd5a45..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index d6255997..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 543ac321..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 91bf3bb3..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt +++ /dev/null @@ -1,128 +0,0 @@ -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 deleted file mode 100644 index b1bffcba..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ /dev/null @@ -1,216 +0,0 @@ -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 deleted file mode 100644 index 7132111f..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index 1bef6689..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index d3e155a8..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTheme.kt +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index e4071964..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ /dev/null @@ -1,288 +0,0 @@ -// 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 deleted file mode 100644 index ff7d9894..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_alert.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_gear.xml b/wolfskin-design/src/main/res/drawable/wolfskin_gear.xml deleted file mode 100644 index b823a403..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_gear.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml b/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml deleted file mode 100644 index e19e2857..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_grid.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_info.xml b/wolfskin-design/src/main/res/drawable/wolfskin_info.xml deleted file mode 100644 index 7bcf471d..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_info.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_play.xml b/wolfskin-design/src/main/res/drawable/wolfskin_play.xml deleted file mode 100644 index 96af58e1..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_play.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_right.xml b/wolfskin-design/src/main/res/drawable/wolfskin_right.xml deleted file mode 100644 index 7a1835b2..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_right.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_sort.xml b/wolfskin-design/src/main/res/drawable/wolfskin_sort.xml deleted file mode 100644 index fd08bb9f..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_sort.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_sparkle.xml b/wolfskin-design/src/main/res/drawable/wolfskin_sparkle.xml deleted file mode 100644 index aa2679ce..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_sparkle.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_tray.xml b/wolfskin-design/src/main/res/drawable/wolfskin_tray.xml deleted file mode 100644 index b902f2fd..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_tray.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_tv.xml b/wolfskin-design/src/main/res/drawable/wolfskin_tv.xml deleted file mode 100644 index 52f1c851..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_tv.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_users.xml b/wolfskin-design/src/main/res/drawable/wolfskin_users.xml deleted file mode 100644 index adbd06d3..00000000 --- a/wolfskin-design/src/main/res/drawable/wolfskin_users.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - 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 deleted file mode 100644 index 07dec9a8..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/NavSpringTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 62d40759..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index 77052d6f..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -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 deleted file mode 100644 index d99323a9..00000000 --- a/wolfskin-design/src/test/resources/wolfskin-shell.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "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 deleted file mode 100644 index 47b010b0..00000000 --- a/wolfskin-design/tokens.css +++ /dev/null @@ -1,179 +0,0 @@ -/* 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 deleted file mode 100644 index 95fa62f2..00000000 --- a/wolfskin-design/tokens.json +++ /dev/null @@ -1,1592 +0,0 @@ -{ - "$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 deleted file mode 100644 index 128b08d1..00000000 --- a/wolfskin-design/tokens.sha256 +++ /dev/null @@ -1,5 +0,0 @@ -1438f7ca4a2b6f951c9e643ab0afacbf2f7fc98e57905295d518c6225e054de4 generate_tokens.py -408b7c3f4db2028deda97ac0570a57a4da4877917fd0980b2bfa5afe8f8fa790 shell-tokens.json -1037c46c9d804f9349e927257de13ba32cbd1c05e208ec0bc1d345f4b465f1eb src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -c133085ef60612df9753ef3681446d9ccb2eb29b1ee1955b86254714c67525e3 tokens.css -5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json