From 7fb7aec4712c9664a8849990420782f807da4eeb Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Wed, 5 Aug 2026 18:12:18 -0400
Subject: [PATCH 01/24] feat(nova): restructure the game detail window around
its artwork
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The window opened as a single scroll column stacking ten concerns: artwork,
identity, launch controls, three inline sub-sheets, MangoHud status, two insight
cards and the whole artwork studio. This makes the landing screen answer one
question — do I want to play this — and gives everything else a destination.
Overview goes full-bleed. It reuses NovaLibraryCinematicBackdrop, so hero-to-poster
fallback, the crossfade and the theme scrims come with it rather than being rebuilt.
Curated logo artwork becomes the title treatment at 200x64dp; the same asset was
previously crushed into 56%x46% of a 136dp thumbnail inside a card inside a sheet.
Below it, one reading order: who the game is, a hairline, what the primary action
will do, then what you can do.
The launch path loses its modals. onPrimaryLaunch branched three ways and only one
of them launched; the other two raised a bottom sheet and a stock AlertDialog over
the content. Neither needed porting, because the design already had the right home
for each: a choice of where to run belongs in the Launch mode destination, and the
preflight review is a statement about the profile, so it expands the status line in
place and turns the alert's three buttons into the action lane. That removes the
last raw AlertDialog in the detail, which the guards already forbade elsewhere.
Artwork takes the whole window rather than a side panel. NovaArtworkStudio opens
with a Row of weighted Columns and cannot fold into 60% of a landscape shell; Tune
and Launch mode keep the panel.
Both destinations lay their glass over an opaque ground. surfaces.panel is sheet
glass, scaled again by the user's menu opacity, which is right over a dimmed sheet
and wrong over a hero — without a ground beneath it the Overview read straight
through Artwork.
singleTop, because a fast double-press on a poster otherwise stacks two windows.
Session-aware verbs and the playtime gauge are deliberately absent: both need data
this window does not yet receive.
---
.../papi/nova/ui/NovaGameDetailComposeTest.kt | 14 +-
app/src/main/AndroidManifest.xml | 1 +
.../papi/nova/ui/NovaGameDetailActivity.kt | 198 ++++-----
.../com/papi/nova/ui/NovaGameDetailContent.kt | 267 +++++------
.../nova/ui/NovaGameDetailDestinations.kt | 283 ++++++++++++
.../papi/nova/ui/NovaGameDetailOverview.kt | 415 ++++++++++++++++++
.../ui/NovaArtworkStudioSourceGuardTest.kt | 28 +-
.../nova/ui/NovaComposeSourceGuardTest.kt | 190 +++-----
.../papi/nova/ui/NovaLaunchSourceGuardTest.kt | 106 +++--
.../papi/nova/ui/NovaThemeResourcesTest.kt | 25 +-
10 files changed, 1097 insertions(+), 430 deletions(-)
create mode 100644 app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
create mode 100644 app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
diff --git a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt
index a94d3052..990ebdd1 100644
--- a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt
+++ b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt
@@ -7,6 +7,7 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.papi.nova.api.PolarisClientSettings
+import com.papi.nova.api.PolarisApiClient
import com.papi.nova.shared.polaris.model.PolarisGame
import com.papi.nova.ui.compose.NovaComposeTheme
import org.junit.Assert.assertTrue
@@ -113,7 +114,18 @@ class NovaGameDetailComposeTest {
iconAvailable = false,
iconPresentationKey = "",
iconLoader = {},
- coverLoader = {}
+ coverLoader = {},
+ destination = NovaGameDetailDestination.OVERVIEW,
+ steamDecision = null,
+ reviewExpanded = false,
+ // No host is reachable under test; the backdrop simply draws nothing.
+ apiClient = PolarisApiClient(
+ InstrumentationRegistry.getInstrumentation().targetContext,
+ "",
+ ),
+ sourceLabel = "Steam",
+ onDestination = {},
+ onSteamChoice = {},
)
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cbd91979..d8df620b 100755
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -294,6 +294,7 @@
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
index c07c712a..1f0ea738 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
@@ -176,6 +176,17 @@ class NovaGameDetailActivity : NovaActivity() {
/** Artwork and MangoHUD edits made here; handed back so the library can merge them. */
private var updatedGame: PolarisGame? = null
+ private var destination by mutableStateOf(NovaGameDetailDestination.OVERVIEW)
+
+ /**
+ * Set when Polaris reports desktop Steam active. It turns Launch mode into the
+ * three-way choice that used to be a bottom sheet raised over the content.
+ */
+ private var steamDecision by mutableStateOf(null)
+
+ /** The preflight review, expanded on the Overview rather than raised as an alert. */
+ private var reviewExpanded by mutableStateOf(false)
+
override fun onCreate(savedInstanceState: Bundle?) {
NovaThemeManager.applyTheme(this)
super.onCreate(savedInstanceState)
@@ -206,6 +217,7 @@ class NovaGameDetailActivity : NovaActivity() {
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
+ if (dismissActiveDetailDestination()) return
publishGameUpdate()
finish()
}
@@ -215,6 +227,23 @@ class NovaGameDetailActivity : NovaActivity() {
setUpDetail(game, apiClient)
}
+ /**
+ * Unwinds one level: an expanded review collapses, a destination returns to the
+ * Overview, and only then does back leave for the library.
+ */
+ private fun dismissActiveDetailDestination(): Boolean = when {
+ reviewExpanded -> {
+ reviewExpanded = false
+ true
+ }
+ destination != NovaGameDetailDestination.OVERVIEW -> {
+ destination = NovaGameDetailDestination.OVERVIEW
+ steamDecision = null
+ true
+ }
+ else -> false
+ }
+
/** Carries artwork or MangoHUD edits back even when the window closes without launching. */
private fun publishGameUpdate() {
val game = updatedGame ?: return
@@ -360,6 +389,30 @@ class NovaGameDetailActivity : NovaActivity() {
loadOptimization(profilePreference, usesVirtualDisplay = mode == "virtual_display")
}
+ fun launchConfirmed(mirrorDesktop: Boolean, forcePrivateAfterSteamClose: Boolean = false) {
+ onLaunch?.invoke(
+ currentGame.copy(mangohud = mangoHudEnabled),
+ uiState.playUsesVirtualDisplay,
+ mirrorDesktop,
+ forcePrivateAfterSteamClose,
+ profilePreference,
+ optimizationState.rawOptimization
+ )
+ finish()
+ }
+
+ fun resetProfile() {
+ resetWorking = true
+ lifecycleScope.launch {
+ withContext(Dispatchers.IO) {
+ apiClient.clearOptimizerProfile(deviceName, currentGame.name)
+ }
+ optimizationState = NovaGameDetailOptimizationState()
+ loadOptimization(profilePreference)
+ resetWorking = false
+ }
+ }
+
setContentView(
ComposeView(this).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
@@ -407,47 +460,39 @@ class NovaGameDetailActivity : NovaActivity() {
coverContentDescription = getString(R.string.nova_a11y_game_cover),
onPrimaryLaunch = {
if (!uiState.playEnabled) return@NovaGameDetailContent
- fun launchConfirmed(mirrorDesktop: Boolean, forcePrivateAfterSteamClose: Boolean = false) {
- onLaunch?.invoke(
- currentGame.copy(mangohud = mangoHudEnabled),
- uiState.playUsesVirtualDisplay,
- mirrorDesktop,
- forcePrivateAfterSteamClose,
- profilePreference,
- optimizationState.rawOptimization
- )
- finish()
- }
- val desktopSteamDecision = NovaDesktopSteamLaunchDecision.from(
+ val decision = NovaDesktopSteamLaunchDecision.from(
uiState,
optimizationState.rawOptimization
)
- if (desktopSteamDecision.required) {
- showDesktopSteamLaunchDecision(
- decision = desktopSteamDecision,
- onPrivateStream = { launchConfirmed(mirrorDesktop = false, forcePrivateAfterSteamClose = false) },
- onMirrorDesktop = { launchConfirmed(mirrorDesktop = true, forcePrivateAfterSteamClose = false) },
- onForcePrivateAfterSteamClose = { launchConfirmed(mirrorDesktop = false, forcePrivateAfterSteamClose = true) }
- )
- } else if (optimizationState.reviewRequired) {
- showPreflightReview(
- optimizationState = optimizationState,
- onLaunchConfirmed = { launchConfirmed(false) },
- onRetryHighFps = { retryHighFpsTrial() },
- onResetProfile = {
- resetWorking = true
- lifecycleScope.launch {
- withContext(Dispatchers.IO) {
- apiClient.clearOptimizerProfile(deviceName, currentGame.name)
- }
- optimizationState = NovaGameDetailOptimizationState()
- loadOptimization(profilePreference)
- resetWorking = false
- }
- }
- )
- } else {
- launchConfirmed(false)
+ when {
+ // A choice of where to run belongs in the destination named that,
+ // not in a sheet raised over the artwork.
+ decision.required -> {
+ steamDecision = decision
+ destination = NovaGameDetailDestination.LAUNCH_MODE
+ }
+ // The review is a statement about the profile, and the status
+ // line is where the profile lives, so it expands in place.
+ optimizationState.reviewRequired && !reviewExpanded -> {
+ reviewExpanded = true
+ }
+ else -> launchConfirmed(false)
+ }
+ },
+ destination = destination,
+ steamDecision = steamDecision,
+ reviewExpanded = reviewExpanded,
+ apiClient = apiClient,
+ sourceLabel = currentGame.sourceLabel,
+ onDestination = { next -> destination = next },
+ onSteamChoice = { choice ->
+ when (choice) {
+ NovaSteamLaunchChoice.PRIVATE_STREAM ->
+ launchConfirmed(mirrorDesktop = false, forcePrivateAfterSteamClose = false)
+ NovaSteamLaunchChoice.MIRROR_DESKTOP ->
+ launchConfirmed(mirrorDesktop = true, forcePrivateAfterSteamClose = false)
+ NovaSteamLaunchChoice.CLOSE_STEAM_THEN_PRIVATE ->
+ launchConfirmed(mirrorDesktop = false, forcePrivateAfterSteamClose = true)
}
},
onLaunchOptions = {
@@ -480,12 +525,10 @@ class NovaGameDetailActivity : NovaActivity() {
usesVirtualDisplay = option.usesVirtualDisplay
)
if (desktopSteamDecision.required) {
- showDesktopSteamLaunchDecision(
- decision = desktopSteamDecision,
- onPrivateStream = { launchSelected(mirrorDesktop = false, forcePrivateAfterSteamClose = false) },
- onMirrorDesktop = { launchSelected(mirrorDesktop = true, forcePrivateAfterSteamClose = false) },
- onForcePrivateAfterSteamClose = { launchSelected(mirrorDesktop = false, forcePrivateAfterSteamClose = true) }
- )
+ // Same destination the primary action routes to; picking an
+ // explicit option does not change where the choice belongs.
+ steamDecision = desktopSteamDecision
+ destination = NovaGameDetailDestination.LAUNCH_MODE
} else {
launchSelected(mirrorDesktop = false)
}
@@ -919,74 +962,7 @@ class NovaGameDetailActivity : NovaActivity() {
)
}
- private fun showPreflightReview(
- optimizationState: NovaGameDetailOptimizationState,
- onLaunchConfirmed: () -> Unit,
- onRetryHighFps: () -> Unit,
- onResetProfile: () -> Unit
- ) {
- val reason = optimizationState.reviewReason.ifBlank { "fps_override" }
- val dialog = AlertDialog.Builder(this@NovaGameDetailActivity)
- .setTitle(R.string.nova_library_preflight_review_title)
- .setMessage(getString(R.string.nova_library_preflight_review_message, reason))
- .setPositiveButton(R.string.nova_library_preflight_launch) { _, _ -> onLaunchConfirmed() }
- .setNeutralButton(R.string.nova_library_retry_high_fps) { _, _ -> onRetryHighFps() }
- .setNegativeButton(R.string.nova_library_reset_game_profile) { _, _ -> onResetProfile() }
- .create()
- NovaSheetChrome.applyMenuOpacityToLegacyAlert(dialog)
- dialog.show()
- }
- private fun showDesktopSteamLaunchDecision(
- decision: NovaDesktopSteamLaunchDecision,
- onPrivateStream: () -> Unit,
- onMirrorDesktop: () -> Unit,
- onForcePrivateAfterSteamClose: () -> Unit
- ) {
- val sheet = BottomSheetDialog(this@NovaGameDetailActivity)
- val composeView = ComposeView(this@NovaGameDetailActivity).apply {
- setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
- background = NovaSheetChrome.createSheetBackground(this@NovaGameDetailActivity)
- setContent {
- NovaComposeTheme {
- NovaDesktopSteamLaunchDecisionContent(
- title = stringResource(R.string.nova_desktop_steam_title),
- message = decision.reason.ifBlank {
- stringResource(R.string.nova_desktop_steam_message)
- },
- privateStreamLabel = stringResource(R.string.nova_desktop_steam_private_stream),
- privateStreamUnavailableReason = decision.privateStreamUnavailableReason,
- privateStreamEnabled = decision.privateStreamEnabled,
- mirrorDesktopLabel = stringResource(R.string.nova_desktop_steam_mirror_desktop),
- mirrorDesktopEnabled = decision.mirrorDesktopEnabled,
- mirrorDesktopCaption = stringResource(R.string.nova_desktop_steam_mirror_caption),
- forcePrivateLabel = decision.forcePrivateAfterSteamCloseLabel.ifBlank {
- stringResource(R.string.nova_desktop_steam_force_private)
- },
- forcePrivateEnabled = decision.forcePrivateAfterSteamCloseEnabled,
- forcePrivateCaption = stringResource(R.string.nova_desktop_steam_force_private_caption),
- cancelLabel = stringResource(R.string.nova_desktop_steam_cancel),
- onPrivateStream = {
- sheet.dismiss()
- onPrivateStream()
- },
- onMirrorDesktop = {
- sheet.dismiss()
- onMirrorDesktop()
- },
- onForcePrivateAfterSteamClose = {
- sheet.dismiss()
- onForcePrivateAfterSteamClose()
- },
- onCancel = { sheet.dismiss() }
- )
- }
- }
- }
- sheet.setContentView(composeView)
- sheet.setOnShowListener { expandBottomSheet(sheet, composeView) }
- sheet.show()
- }
private fun expandBottomSheet(bottomSheetDialog: BottomSheetDialog?, contentView: View) {
val sheet = bottomSheetDialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet) ?: return
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
index 434f76cc..69c1a9bf 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
@@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
@@ -213,7 +214,7 @@ data class NovaSteamLaunchModeOptionsState(
@Composable
-fun NovaGameDetailContent(
+internal fun NovaGameDetailContent(
uiState: NovaGameDetailUiState,
launchIntro: String,
recommendedBadge: String,
@@ -280,152 +281,154 @@ fun NovaGameDetailContent(
iconPresentationKey: String,
iconLoader: (ImageView) -> Unit,
iconContentDescription: String = "",
- coverLoader: (ImageView) -> Unit
+ coverLoader: (ImageView) -> Unit,
+ destination: NovaGameDetailDestination,
+ steamDecision: NovaDesktopSteamLaunchDecision?,
+ reviewExpanded: Boolean,
+ apiClient: PolarisApiClient,
+ sourceLabel: String,
+ onDestination: (NovaGameDetailDestination) -> Unit,
+ onSteamChoice: (NovaSteamLaunchChoice) -> Unit,
) {
- val colors = LocalNovaComposeColors.current
- val surfaces = LocalNovaLibrarySurfaces.current
val verticalScroll = rememberScrollState()
val playFocusRequester = remember { FocusRequester() }
val detailsFocusRequester = remember { FocusRequester() }
- Column(
- modifier = modifier
- .fillMaxWidth()
- .fillMaxHeight()
- .clip(RoundedCornerShape(topStart = NovaSheetChrome.SHEET_CORNER_RADIUS_DP.dp, topEnd = NovaSheetChrome.SHEET_CORNER_RADIUS_DP.dp))
- .background(surfaces.panel)
- ) {
- NovaGameDetailScrollableContent(
- scrollState = verticalScroll,
- modifier = Modifier.weight(1f)
- ) {
- GameDetailsPanel(
- uiState = uiState,
- lastPlayedText = lastPlayedText,
- coverContentDescription = coverContentDescription,
- coverLoader = coverLoader,
- artworkState = artworkState,
- heroAvailable = heroAvailable,
- heroPresentationKey = heroPresentationKey,
- heroLoader = heroLoader,
- heroContentDescription = heroContentDescription,
- logoAvailable = logoAvailable,
- logoPresentationKey = logoPresentationKey,
- logoLoader = logoLoader,
- logoContentDescription = logoContentDescription,
- iconAvailable = iconAvailable,
- iconPresentationKey = iconPresentationKey,
- iconLoader = iconLoader,
- iconContentDescription = iconContentDescription,
- )
-
- LaunchControlsPanel(
- uiState = uiState,
- launchIntro = launchIntro,
- launchModeTitle = launchModeTitle,
- recommendedBadge = recommendedBadge,
- launchOptionsLabel = launchOptionsLabel,
- profilePreferenceLabel = profilePreferenceLabel,
- profileSummary = optimizationState.profileSummary,
- resetProfileLabel = resetProfileLabel,
- resetProfileWorking = resetProfileWorking,
- headlessModeLabel = headlessModeLabel,
- virtualDisplayModeLabel = virtualDisplayModeLabel,
- playFocusRequester = playFocusRequester,
- detailsFocusRequester = detailsFocusRequester,
- onLaunchOptions = onLaunchOptions,
- onLaunchModeSelected = onLaunchModeSelected,
- onProfilePreference = onProfilePreference,
- onRetryHighFps = onRetryHighFps,
- onResetProfile = onResetProfile
- )
+ Box(modifier = modifier.fillMaxSize()) {
+ NovaGameDetailOverview(
+ uiState = uiState,
+ apiClient = apiClient,
+ playLabel = playLabel,
+ lastPlayedText = lastPlayedText,
+ sourceLabel = sourceLabel,
+ optimizationState = optimizationState,
+ reviewExpanded = reviewExpanded,
+ showLaunchModeAction = uiState.showLaunchOptionsButton,
+ logoAvailable = logoAvailable,
+ logoPresentationKey = logoPresentationKey,
+ logoLoader = logoLoader,
+ logoContentDescription = logoContentDescription,
+ playFocusRequester = playFocusRequester,
+ onPrimaryLaunch = onPrimaryLaunch,
+ onRetryHighFps = onRetryHighFps,
+ onResetProfile = onResetProfile,
+ onDestination = onDestination,
+ )
- launchOptionsState?.let {
- NovaLaunchOptionsSheet(
- state = it,
- onLaunch = onLaunchOptionSelected,
- onDismiss = onDismissLaunchOptions
- )
- }
+ when (destination) {
+ NovaGameDetailDestination.OVERVIEW -> Unit
- profileOptionsState?.let {
- NovaProfilePreferenceSheet(
- state = it,
- onSelected = onProfilePreferenceSelected,
- onDismiss = onDismissProfileOptions
- )
+ NovaGameDetailDestination.LAUNCH_MODE -> NovaGameDetailPanel(
+ eyebrow = stringResource(R.string.nova_library_launch_mode_title),
+ headline = launchModeTitle,
+ readout = optimizationState.profileSummary?.selectedLine.orEmpty(),
+ scrollState = verticalScroll,
+ ) {
+ val decision = steamDecision
+ if (decision != null) {
+ NovaDesktopSteamLaunchDecisionRows(
+ decision = decision,
+ onChoice = onSteamChoice,
+ )
+ } else {
+ LaunchControlsPanel(
+ uiState = uiState,
+ launchIntro = launchIntro,
+ launchModeTitle = launchModeTitle,
+ recommendedBadge = recommendedBadge,
+ launchOptionsLabel = launchOptionsLabel,
+ profilePreferenceLabel = profilePreferenceLabel,
+ profileSummary = optimizationState.profileSummary,
+ resetProfileLabel = resetProfileLabel,
+ resetProfileWorking = resetProfileWorking,
+ headlessModeLabel = headlessModeLabel,
+ virtualDisplayModeLabel = virtualDisplayModeLabel,
+ playFocusRequester = playFocusRequester,
+ detailsFocusRequester = detailsFocusRequester,
+ onLaunchOptions = onLaunchOptions,
+ onLaunchModeSelected = onLaunchModeSelected,
+ onProfilePreference = onProfilePreference,
+ onRetryHighFps = onRetryHighFps,
+ onResetProfile = onResetProfile
+ )
+ launchOptionsState?.let {
+ NovaLaunchOptionsSheet(
+ state = it,
+ onLaunch = onLaunchOptionSelected,
+ onDismiss = onDismissLaunchOptions
+ )
+ }
+ }
}
- SteamLaunchModeCard(
- visible = uiState.showSteamLaunchMode,
- label = steamLaunchLabel,
- modeLabel = steamLaunchModeLabel,
- caption = steamLaunchCaption,
- warning = uiState.steamLaunchWarning,
- onClick = onSteamLaunchMode
- )
-
- steamLaunchOptionsState?.let { state ->
- NovaSteamLaunchModeSheet(
- state = state,
- onSelected = onSteamLaunchModeSelected,
- onDismiss = onDismissSteamLaunchModeOptions
+ NovaGameDetailDestination.TUNE -> NovaGameDetailPanel(
+ eyebrow = stringResource(R.string.nova_library_launch_options_secondary),
+ headline = optimizationState.profileSummary?.primaryLaunchLabel.orEmpty()
+ .ifBlank { profilePreferenceLabel },
+ readout = optimizationState.profileSummary?.freshnessLine.orEmpty(),
+ scrollState = verticalScroll,
+ ) {
+ profileOptionsState?.let {
+ NovaProfilePreferenceSheet(
+ state = it,
+ onSelected = onProfilePreferenceSelected,
+ onDismiss = onDismissProfileOptions
+ )
+ }
+ SteamLaunchModeCard(
+ visible = uiState.showSteamLaunchMode,
+ label = steamLaunchLabel,
+ modeLabel = steamLaunchModeLabel,
+ caption = steamLaunchCaption,
+ warning = uiState.steamLaunchWarning,
+ onClick = onSteamLaunchMode
)
+ steamLaunchOptionsState?.let { state ->
+ NovaSteamLaunchModeSheet(
+ state = state,
+ onSelected = onSteamLaunchModeSelected,
+ onDismiss = onDismissSteamLaunchModeOptions
+ )
+ }
+ if (mangoHudEnabled) {
+ MangoHudPassiveStatus(
+ label = mangoHudStatusLabel,
+ caption = mangoHudStatusCaption,
+ warning = mangoHudWarning
+ )
+ }
+ optimizationState.ai?.let { InsightCard(card = it) }
+ optimizationState.stability?.let { InsightCard(card = it) }
}
- if (mangoHudEnabled) {
- MangoHudPassiveStatus(
- label = mangoHudStatusLabel,
- caption = mangoHudStatusCaption,
- warning = mangoHudWarning
+ // The studio opens with a Row of weighted Columns, so it needs the window
+ // rather than the 438dp panel the other two destinations use.
+ NovaGameDetailDestination.ARTWORK -> NovaGameDetailFullScreen(
+ eyebrow = stringResource(R.string.nova_artwork_studio_title),
+ headline = uiState.game.name,
+ scrollState = verticalScroll,
+ ) {
+ NovaArtworkStudio(
+ state = artworkState,
+ initialQuery = uiState.game.name,
+ onRefresh = onRefreshArtwork,
+ onSearch = onSearchArtwork,
+ onIdentitySelected = onIdentitySelected,
+ onChangeIdentity = onIdentityChange,
+ onKindSelected = onKindSelected,
+ onChoiceSelected = onChoiceSelected,
+ onReset = onStudioAction,
+ onApply = onApplyArtwork,
+ onCancel = onStudioAction,
+ onClear = onClearArtwork,
+ onTransform = onLogoTransform,
+ candidatePreviewLoader = candidatePreviewLoader,
+ choicePreviewLoader = choicePreviewLoader,
+ currentArtworkPresentationKey = currentArtworkPresentationKey,
+ currentArtworkLoader = currentArtworkLoader,
)
}
-
- optimizationState.ai?.let {
- InsightCard(card = it)
- }
-
- optimizationState.stability?.let {
- InsightCard(card = it)
- }
-
- NovaArtworkStudio(
- state = artworkState,
- initialQuery = uiState.game.name,
- onRefresh = onRefreshArtwork,
- onSearch = onSearchArtwork,
- onIdentitySelected = onIdentitySelected,
- onChangeIdentity = onIdentityChange,
- onKindSelected = onKindSelected,
- onChoiceSelected = onChoiceSelected,
- onReset = onStudioAction,
- onApply = onApplyArtwork,
- onCancel = onStudioAction,
- onClear = onClearArtwork,
- onTransform = onLogoTransform,
- candidatePreviewLoader = candidatePreviewLoader,
- choicePreviewLoader = choicePreviewLoader,
- currentArtworkPresentationKey = currentArtworkPresentationKey,
- currentArtworkLoader = currentArtworkLoader,
- )
-
- NovaControllerHintBar(
- hints = novaGameDetailControllerHints(),
- compact = true,
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 14.dp, end = 14.dp, top = 12.dp)
- )
}
-
- NovaGameDetailLaunchFooter(
- playLabel = playLabel,
- enabled = uiState.playEnabled,
- onPrimaryLaunch = onPrimaryLaunch,
- playFocusRequester = playFocusRequester,
- detailsFocusRequester = detailsFocusRequester,
- contentInsets = WindowInsets.safeContent.only(WindowInsetsSides.Bottom)
- )
}
}
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
new file mode 100644
index 00000000..dd0b82c9
--- /dev/null
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
@@ -0,0 +1,283 @@
+package com.papi.nova.ui
+
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+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.WindowInsets
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeContent
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.em
+import androidx.compose.ui.unit.sp
+import com.papi.nova.R
+import com.papi.nova.ui.compose.LocalNovaComposeColors
+import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces
+import com.papi.nova.ui.compose.NovaControllerHint
+import com.papi.nova.ui.compose.NovaControllerHintBar
+import com.papi.nova.ui.compose.NovaFocusableCard
+
+/** The three ways a launch can go when Polaris reports desktop Steam active. */
+internal enum class NovaSteamLaunchChoice {
+ PRIVATE_STREAM,
+ MIRROR_DESKTOP,
+ CLOSE_STEAM_THEN_PRIVATE,
+}
+
+/**
+ * A drill-in that sits beside the game rather than on top of it. The header is pinned and
+ * the body scrolls, so focus drives the scroll rather than the reverse.
+ */
+@Composable
+internal fun NovaGameDetailPanel(
+ eyebrow: String,
+ headline: String,
+ readout: String,
+ scrollState: ScrollState,
+ content: @Composable () -> Unit,
+) {
+ val colors = LocalNovaComposeColors.current
+ val surfaces = LocalNovaLibrarySurfaces.current
+
+ Box(modifier = Modifier.fillMaxSize().background(colors.window.copy(alpha = 0.58f))) {
+ Column(
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .fillMaxHeight()
+ .fillMaxWidth(NOVA_DETAIL_PANEL_WIDTH_FRACTION)
+ .background(colors.window)
+ .background(surfaces.panel)
+ .windowInsetsPadding(WindowInsets.safeContent)
+ .padding(horizontal = NovaGameDetailInset, vertical = 20.dp)
+ .testTag("nova-game-detail-panel"),
+ ) {
+ NovaGameDetailDestinationHeader(eyebrow, headline, readout)
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .verticalScroll(scrollState),
+ content = { content() },
+ )
+ NovaGameDetailDestinationHints()
+ }
+ }
+}
+
+/**
+ * A drill-in that needs the window. Used by Artwork, whose studio lays itself out as a
+ * Row of weighted Columns and cannot fold into a panel.
+ */
+@Composable
+internal fun NovaGameDetailFullScreen(
+ eyebrow: String,
+ headline: String,
+ scrollState: ScrollState,
+ content: @Composable () -> Unit,
+) {
+ val colors = LocalNovaComposeColors.current
+ val surfaces = LocalNovaLibrarySurfaces.current
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(colors.window)
+ .background(surfaces.panel)
+ .windowInsetsPadding(WindowInsets.safeContent)
+ .padding(horizontal = NovaGameDetailInset, vertical = 20.dp)
+ .testTag("nova-game-detail-fullscreen"),
+ ) {
+ NovaGameDetailDestinationHeader(eyebrow, headline, readout = "")
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .verticalScroll(scrollState),
+ content = { content() },
+ )
+ NovaGameDetailDestinationHints()
+ }
+}
+
+/** Every destination says how to act and how to get back. */
+@Composable
+private fun NovaGameDetailDestinationHints() {
+ NovaControllerHintBar(
+ hints = listOf(
+ NovaControllerHint(
+ key = stringResource(R.string.nova_controller_hint_a),
+ label = stringResource(R.string.nova_controller_hint_select),
+ ),
+ NovaControllerHint(
+ key = stringResource(R.string.nova_controller_hint_b),
+ label = stringResource(R.string.nova_controller_hint_back),
+ ),
+ ),
+ compact = true,
+ modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
+ )
+}
+
+@Composable
+private fun NovaGameDetailDestinationHeader(eyebrow: String, headline: String, readout: String) {
+ val colors = LocalNovaComposeColors.current
+ Column(modifier = Modifier.padding(bottom = 14.dp)) {
+ Text(
+ text = eyebrow,
+ color = colors.textMuted,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 0.22.em,
+ )
+ Text(
+ text = headline,
+ color = colors.textPrimary,
+ fontSize = 22.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 3.dp),
+ )
+ if (readout.isNotBlank()) {
+ Text(
+ text = readout,
+ color = colors.textSecondary,
+ fontSize = 11.sp,
+ letterSpacing = 0.10.em,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 5.dp),
+ )
+ }
+ }
+}
+
+/**
+ * The desktop-Steam choice, as rows in Launch mode rather than a sheet over the artwork.
+ * A blocked option stays visible and inert: hiding it loses the reason it is blocked,
+ * which is the part worth reading.
+ */
+@Composable
+internal fun NovaDesktopSteamLaunchDecisionRows(
+ decision: NovaDesktopSteamLaunchDecision,
+ onChoice: (NovaSteamLaunchChoice) -> Unit,
+) {
+ val colors = LocalNovaComposeColors.current
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth().testTag("nova-game-detail-steam-decision"),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(NovaGameDetailCornerRadius))
+ .background(colors.warning.copy(alpha = 0.13f))
+ .border(
+ 1.dp,
+ colors.warning.copy(alpha = 0.46f),
+ RoundedCornerShape(NovaGameDetailCornerRadius),
+ )
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.nova_desktop_steam_title),
+ color = colors.textPrimary,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = decision.reason.ifBlank {
+ stringResource(R.string.nova_desktop_steam_message)
+ },
+ color = colors.textSecondary,
+ fontSize = 11.sp,
+ lineHeight = 15.sp,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ }
+
+ NovaSteamChoiceRow(
+ label = stringResource(R.string.nova_desktop_steam_private_stream),
+ caption = decision.privateStreamUnavailableReason,
+ enabled = decision.privateStreamEnabled,
+ onClick = { onChoice(NovaSteamLaunchChoice.PRIVATE_STREAM) },
+ )
+ if (decision.forcePrivateAfterSteamCloseEnabled) {
+ NovaSteamChoiceRow(
+ label = decision.forcePrivateAfterSteamCloseLabel.ifBlank {
+ stringResource(R.string.nova_desktop_steam_force_private)
+ },
+ caption = stringResource(R.string.nova_desktop_steam_force_private_caption),
+ enabled = true,
+ onClick = { onChoice(NovaSteamLaunchChoice.CLOSE_STEAM_THEN_PRIVATE) },
+ )
+ }
+ NovaSteamChoiceRow(
+ label = stringResource(R.string.nova_desktop_steam_mirror_desktop),
+ caption = stringResource(R.string.nova_desktop_steam_mirror_caption),
+ enabled = decision.mirrorDesktopEnabled,
+ onClick = { onChoice(NovaSteamLaunchChoice.MIRROR_DESKTOP) },
+ )
+ }
+}
+
+@Composable
+private fun NovaSteamChoiceRow(
+ label: String,
+ caption: String,
+ enabled: Boolean,
+ onClick: () -> Unit,
+) {
+ val colors = LocalNovaComposeColors.current
+ NovaFocusableCard(
+ onClick = onClick,
+ enabled = enabled,
+ contentDescription = label,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
+ Text(
+ text = label,
+ color = if (enabled) colors.textPrimary else colors.textMuted,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ if (caption.isNotBlank()) {
+ Text(
+ text = caption,
+ color = colors.textMuted,
+ fontSize = 11.sp,
+ lineHeight = 14.sp,
+ modifier = Modifier.padding(top = 3.dp),
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Wide enough for the insight cards, which carry a single-line profile badge that
+ * truncated at 53%, and still narrow enough to keep the game present beside it.
+ */
+private const val NOVA_DETAIL_PANEL_WIDTH_FRACTION = 0.60f
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
new file mode 100644
index 00000000..6e68c2f0
--- /dev/null
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
@@ -0,0 +1,415 @@
+package com.papi.nova.ui
+
+import android.widget.ImageView
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+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.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeContent
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.key
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.em
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import com.papi.nova.R
+import com.papi.nova.api.PolarisApiClient
+import com.papi.nova.shared.polaris.model.PolarisGame
+import com.papi.nova.ui.compose.LocalNovaComposeColors
+import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces
+import com.papi.nova.ui.compose.NovaActionButton
+import com.papi.nova.ui.compose.NovaControllerHint
+import com.papi.nova.ui.compose.NovaControllerHintBar
+
+/** Where the detail window currently is. Back unwinds one level before leaving. */
+internal enum class NovaGameDetailDestination { OVERVIEW, LAUNCH_MODE, TUNE, ARTWORK }
+
+/** Content insets shared by the Overview and the destinations that sit beside it. */
+internal val NovaGameDetailInset = 28.dp
+internal val NovaGameDetailFloor = 58.dp
+
+/** Every focusable control clears the accessible target floor. */
+internal val NovaGameDetailActionHeight = 48.dp
+
+/** Matches the library's surface radius; the sharp edge was a deliberate choice there. */
+internal val NovaGameDetailCornerRadius = 8.dp
+
+/**
+ * The landing screen of the detail window.
+ *
+ * The artwork is the subject and nothing sits on it in a card: the content block rests on
+ * the scrim floor in one reading order — who the game is, a hairline, what pressing the
+ * primary action will do, then what you can do. Layout is complete before the artwork
+ * arrives, so a slow or failed load changes the backdrop and moves nothing.
+ */
+@Composable
+internal fun NovaGameDetailOverview(
+ uiState: NovaGameDetailUiState,
+ apiClient: PolarisApiClient,
+ playLabel: String,
+ lastPlayedText: String?,
+ sourceLabel: String,
+ optimizationState: NovaGameDetailOptimizationState,
+ reviewExpanded: Boolean,
+ showLaunchModeAction: Boolean,
+ logoAvailable: Boolean,
+ logoPresentationKey: String,
+ logoLoader: (ImageView) -> Unit,
+ logoContentDescription: String,
+ playFocusRequester: FocusRequester,
+ onPrimaryLaunch: () -> Unit,
+ onRetryHighFps: () -> Unit,
+ onResetProfile: () -> Unit,
+ onDestination: (NovaGameDetailDestination) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = LocalNovaComposeColors.current
+ val game = uiState.game
+
+ Box(modifier = modifier.fillMaxSize().testTag("nova-game-detail-overview")) {
+ NovaLibraryCinematicBackdrop(game = game, apiClient = apiClient, strength = 1f)
+
+ Column(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets.safeContent)
+ .padding(start = NovaGameDetailInset, end = NovaGameDetailInset, bottom = 10.dp),
+ ) {
+ NovaGameDetailTitle(
+ game = game,
+ logoAvailable = logoAvailable,
+ logoPresentationKey = logoPresentationKey,
+ logoLoader = logoLoader,
+ logoContentDescription = logoContentDescription,
+ )
+
+ Text(
+ text = novaGameDetailIdentityLine(sourceLabel, lastPlayedText, game),
+ color = colors.textSecondary,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.17.em,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 11.dp),
+ )
+
+ // The hairline divides names from numbers: identity above, machine state below.
+ Box(
+ modifier = Modifier
+ .padding(top = 11.dp)
+ .width(330.dp)
+ .height(1.dp)
+ .background(
+ Brush.horizontalGradient(
+ colorStops = arrayOf(
+ 0.0f to colors.accent,
+ 0.30f to colors.accent.copy(alpha = 0.44f),
+ 0.62f to colors.textMuted.copy(alpha = 0.16f),
+ 1.0f to Color.Transparent,
+ ),
+ ),
+ ),
+ )
+
+ NovaGameDetailStatusLine(
+ uiState = uiState,
+ optimizationState = optimizationState,
+ modifier = Modifier.padding(top = 11.dp),
+ )
+
+ if (reviewExpanded) {
+ LaunchProfileReviewNotice(
+ optimizationState = optimizationState,
+ modifier = Modifier.padding(top = 10.dp),
+ )
+ }
+
+ NovaGameDetailActions(
+ uiState = uiState,
+ optimizationState = optimizationState,
+ playLabel = playLabel,
+ reviewExpanded = reviewExpanded,
+ showLaunchModeAction = showLaunchModeAction,
+ playFocusRequester = playFocusRequester,
+ onPrimaryLaunch = onPrimaryLaunch,
+ onRetryHighFps = onRetryHighFps,
+ onResetProfile = onResetProfile,
+ onDestination = onDestination,
+ modifier = Modifier.padding(top = 16.dp),
+ )
+
+ NovaControllerHintBar(
+ hints = novaGameDetailOverviewHints(),
+ compact = true,
+ modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
+ )
+ }
+
+ }
+}
+
+/**
+ * Curated logo artwork replaces the title outright when it is ready at first composition.
+ * A logo that arrives later is ignored: swapping a settled title for one is a visible jump,
+ * and the title is never wrong.
+ */
+@Composable
+private fun NovaGameDetailTitle(
+ game: PolarisGame,
+ logoAvailable: Boolean,
+ logoPresentationKey: String,
+ logoLoader: (ImageView) -> Unit,
+ logoContentDescription: String,
+) {
+ if (logoAvailable) {
+ key(logoPresentationKey) {
+ AndroidView(
+ factory = { context ->
+ ImageView(context).apply {
+ scaleType = ImageView.ScaleType.FIT_START
+ contentDescription = logoContentDescription
+ logoLoader(this)
+ }
+ },
+ modifier = Modifier
+ .sizeIn(maxWidth = 200.dp, maxHeight = 64.dp)
+ .semantics { contentDescription = logoContentDescription }
+ .testTag("nova-game-detail-logo"),
+ )
+ }
+ } else {
+ Text(
+ text = game.name,
+ color = LocalNovaComposeColors.current.textPrimary,
+ fontSize = 38.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = (-0.03).em,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.testTag("nova-game-detail-title"),
+ )
+ }
+}
+
+/**
+ * What pressing the primary action will do, read as an instrument line rather than a chip:
+ * an indicator lamp, then mode and profile set with tabular figures.
+ */
+@Composable
+private fun NovaGameDetailStatusLine(
+ uiState: NovaGameDetailUiState,
+ optimizationState: NovaGameDetailOptimizationState,
+ modifier: Modifier = Modifier,
+) {
+ val colors = LocalNovaComposeColors.current
+ val summary = optimizationState.profileSummary
+ val limited = optimizationState.reviewRequired ||
+ summary?.noticeTone == NovaLaunchProfileNoticeTone.WARNING
+ val lamp = when {
+ limited -> colors.warning
+ summary == null -> colors.textMuted
+ else -> colors.accent
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(9.dp),
+ modifier = modifier.testTag("nova-game-detail-status"),
+ ) {
+ Box(modifier = Modifier.size(7.dp).clip(RoundedCornerShape(percent = 50)).background(lamp))
+ Text(
+ text = novaGameDetailStatusText(uiState, summary),
+ color = colors.textPrimary,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.11.em,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
+/**
+ * The action lane. Play holds first focus; the rail is gated so it never grows a node that
+ * leads nowhere. While a review is expanded the lane becomes the review's own choices —
+ * the three buttons of the alert this replaces.
+ */
+@Composable
+private fun NovaGameDetailActions(
+ uiState: NovaGameDetailUiState,
+ optimizationState: NovaGameDetailOptimizationState,
+ playLabel: String,
+ reviewExpanded: Boolean,
+ showLaunchModeAction: Boolean,
+ playFocusRequester: FocusRequester,
+ onPrimaryLaunch: () -> Unit,
+ onRetryHighFps: () -> Unit,
+ onResetProfile: () -> Unit,
+ onDestination: (NovaGameDetailDestination) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier,
+ ) {
+ NovaActionButton(
+ text = playLabel,
+ onClick = onPrimaryLaunch,
+ enabled = uiState.playEnabled,
+ primary = true,
+ minHeight = NovaGameDetailActionHeight,
+ cornerRadius = NovaGameDetailCornerRadius,
+ fontSize = 15.sp,
+ contentPadding = PaddingValues(horizontal = 22.dp, vertical = 12.dp),
+ modifier = Modifier
+ .focusRequester(playFocusRequester)
+ .testTag("nova-game-detail-primary"),
+ )
+
+ if (reviewExpanded) {
+ if (optimizationState.profileSummary?.showRetryHighFps == true) {
+ NovaGameDetailSecondaryAction(stringResource(R.string.nova_library_retry_high_fps), onRetryHighFps)
+ }
+ NovaGameDetailSecondaryAction(stringResource(R.string.nova_library_reset_game_profile), onResetProfile)
+ } else {
+ if (showLaunchModeAction) {
+ NovaGameDetailSecondaryAction(stringResource(R.string.nova_library_launch_mode_title)) {
+ onDestination(NovaGameDetailDestination.LAUNCH_MODE)
+ }
+ }
+ NovaGameDetailSecondaryAction(stringResource(R.string.nova_library_launch_options_secondary)) {
+ onDestination(NovaGameDetailDestination.TUNE)
+ }
+ NovaGameDetailSecondaryAction(stringResource(R.string.nova_artwork_studio_title)) {
+ onDestination(NovaGameDetailDestination.ARTWORK)
+ }
+ }
+ }
+}
+
+@Composable
+private fun NovaGameDetailSecondaryAction(text: String, onClick: () -> Unit) {
+ val surfaces = LocalNovaLibrarySurfaces.current
+ NovaActionButton(
+ text = text,
+ onClick = onClick,
+ minHeight = NovaGameDetailActionHeight,
+ cornerRadius = NovaGameDetailCornerRadius,
+ fontSize = 13.sp,
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
+ modifier = Modifier
+ .clip(RoundedCornerShape(NovaGameDetailCornerRadius))
+ .background(surfaces.control.copy(alpha = 1f)),
+ )
+}
+
+/**
+ * The alert this replaces stated the reason and offered launch, retry and reset. Expanded
+ * in place it keeps all four, without a dialog landing on the artwork.
+ */
+@Composable
+private fun LaunchProfileReviewNotice(
+ optimizationState: NovaGameDetailOptimizationState,
+ modifier: Modifier = Modifier,
+) {
+ val colors = LocalNovaComposeColors.current
+ val summary = optimizationState.profileSummary
+ val detail = listOf(
+ summary?.noticeDetail,
+ summary?.noticeRecommendation,
+ summary?.reasonLine,
+ ).firstOrNull { !it.isNullOrBlank() }
+ ?: stringResource(
+ R.string.nova_library_preflight_review_message,
+ optimizationState.reviewReason.ifBlank { "fps_override" },
+ )
+
+ Column(
+ modifier = modifier
+ .sizeIn(maxWidth = 520.dp)
+ .clip(RoundedCornerShape(NovaGameDetailCornerRadius))
+ .background(colors.warning.copy(alpha = 0.13f))
+ .border(1.dp, colors.warning.copy(alpha = 0.46f), RoundedCornerShape(NovaGameDetailCornerRadius))
+ .padding(horizontal = 12.dp, vertical = 10.dp)
+ .testTag("nova-game-detail-review"),
+ ) {
+ Text(
+ text = stringResource(R.string.nova_library_preflight_review_title),
+ color = colors.textPrimary,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = detail,
+ color = colors.textSecondary,
+ fontSize = 11.sp,
+ lineHeight = 15.sp,
+ modifier = Modifier.padding(top = 5.dp),
+ )
+ }
+}
+
+/** Source, when it was last played, and its primary genre — who the game is. */
+private fun novaGameDetailIdentityLine(
+ sourceLabel: String,
+ lastPlayedText: String?,
+ game: PolarisGame,
+): String = listOf(sourceLabel, lastPlayedText, game.genres.firstOrNull())
+ .filter { !it.isNullOrBlank() }
+ .joinToString(" · ")
+
+/** Mode, profile and freshness — what the primary action will do. */
+private fun novaGameDetailStatusText(
+ uiState: NovaGameDetailUiState,
+ summary: NovaLaunchProfileSummary?,
+): String {
+ return listOf(
+ uiState.hostStreamDisplayModeLabel.takeIf { uiState.playUsesVirtualDisplay },
+ summary?.selectedLine,
+ summary?.limitingLine?.takeIf { it.isNotBlank() } ?: summary?.freshnessLine,
+ ).filter { !it.isNullOrBlank() }.joinToString(" · ")
+}
+
+/**
+ * B unwinds a level, X reaches Tune. A is not repeated here: the primary action already
+ * carries it, and this window exists to remove duplication.
+ */
+@Composable
+private fun novaGameDetailOverviewHints(): List = listOf(
+ NovaControllerHint(
+ key = stringResource(R.string.nova_controller_hint_b),
+ label = stringResource(R.string.nova_controller_hint_close),
+ ),
+)
diff --git a/app/src/test/java/com/papi/nova/ui/NovaArtworkStudioSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaArtworkStudioSourceGuardTest.kt
index af2e2a7d..1b7fb68f 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaArtworkStudioSourceGuardTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaArtworkStudioSourceGuardTest.kt
@@ -81,7 +81,9 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun detailSheetUsesArtworkStudioInsteadOfThePosterCentricFixMatchFlow() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val studio = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt")
val library = readSource("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt")
val updater = readSource("src/main/java/com/papi/nova/ui/NovaArtworkLibraryUpdater.kt")
@@ -120,7 +122,9 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun studioMutationsAreOwnedByRetainedViewModelAndUiCallbacksAreLifecycleGated() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val library = readSource("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt")
val updater = readSource("src/main/java/com/papi/nova/ui/NovaArtworkLibraryUpdater.kt")
@@ -189,7 +193,9 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun gameDetailsPreservesCachedHeroAvailabilityAndStablePresentationIdentity() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val contentCall = detail.section(
"NovaGameDetailContent(",
"\n loadOptimization(profilePreference)",
@@ -244,7 +250,9 @@ class NovaArtworkStudioSourceGuardTest {
fun applyIsOneExplicitAtomicMutationAndFailureRequiresRefetch() {
val studio = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt")
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val updater = readSource("src/main/java/com/papi/nova/ui/NovaArtworkLibraryUpdater.kt")
val stateReducer = studio.section(
"fun reduce(action: NovaArtworkStudioAction)",
@@ -309,7 +317,9 @@ class NovaArtworkStudioSourceGuardTest {
fun resetAndCancelDiscardDraftLocallyWithoutServerMutation() {
val studio = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt")
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val resetButton = studio.section(
"text = stringResource(R.string.nova_artwork_studio_reset)",
"text = stringResource(R.string.nova_artwork_studio_apply)",
@@ -334,7 +344,9 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun ordinaryDetailAndLibraryLoadingCannotStartChoiceOrProviderMutationTraffic() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val library = readSource("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt")
val detailLoad = detail.section(
"private fun loadArtworkState(",
@@ -351,7 +363,9 @@ class NovaArtworkStudioSourceGuardTest {
@Test
fun studioRefreshUsesTheRetainedPerGameMutationCoordinator() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val library = readSource("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt")
val updater = readSource("src/main/java/com/papi/nova/ui/NovaArtworkLibraryUpdater.kt")
diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
index 5bfae711..192cf7a3 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
@@ -1073,97 +1073,42 @@ class NovaComposeSourceGuardTest {
@Test
fun gameDetailLaunchControlsPrioritizePrimaryPlayFocus() {
val detail = readNovaGameDetail()
- val launchControls = detail.section(
- "private fun LaunchControls(",
- "@Composable\nprivate fun LaunchProfileSummaryInline("
- )
- val launchFooter = detail.section(
- "internal fun NovaGameDetailLaunchFooter(",
- "@Composable\nprivate fun NovaDetailPanel("
- )
- val sheetContent = detail.section(
- "fun NovaGameDetailContent(",
- "@Composable\nprivate fun NovaGameDetailScrollableContent("
+ val overview = detail.section(
+ "internal fun NovaGameDetailOverview(",
+ "private fun NovaGameDetailTitle("
)
- val scrollableContent = detail.section(
- "private fun NovaGameDetailScrollableContent(",
- "@Composable\nprivate fun novaGameDetailControllerHints("
+ val actions = detail.section(
+ "private fun NovaGameDetailActions(",
+ "private fun NovaGameDetailSecondaryAction("
)
- val scrollBody = sheetContent.blockStartingAt("NovaGameDetailScrollableContent(")
assertTrue(
- "game detail should focus the pinned primary play action when the sheet opens",
- sheetContent.contains("val playFocusRequester = remember { FocusRequester() }") &&
- launchFooter.contains("playFocusRequester.requestFocus()") &&
- launchFooter.contains(".focusRequester(playFocusRequester)")
+ "the primary action holds first focus, and nothing scrolls above it",
+ detail.contains("val playFocusRequester = remember { FocusRequester() }") &&
+ actions.contains(".focusRequester(playFocusRequester)") &&
+ actions.contains("primary = true") &&
+ !overview.contains("verticalScroll")
)
assertTrue(
- "game detail should preserve an explicit controller focus route between pinned Play and More details",
- sheetContent.contains("val detailsFocusRequester = remember { FocusRequester() }") &&
- launchControls.contains("detailsFocusRequester = detailsFocusRequester") &&
- launchControls.contains("playFocusRequester = playFocusRequester") &&
- launchFooter.contains(".focusProperties { up = detailsFocusRequester }") &&
- launchControls.contains(".focusProperties { down = playFocusRequester }")
+ "the action lane is one row, so a D-pad walk never leaves it",
+ actions.contains("Row(") &&
+ actions.contains("horizontalArrangement = Arrangement.spacedBy(10.dp)")
)
assertTrue(
- "the pinned footer should keep Play as a full-width primary action above the safe-content bottom inset",
- launchFooter.section("text = playLabel", "enabled = enabled")
- .contains(".fillMaxWidth()\n .focusRequester(playFocusRequester)") &&
- launchFooter.contains(".windowInsetsPadding(contentInsets)") &&
- launchFooter.contains(".padding(start = 14.dp, end = 14.dp, top = 4.dp, bottom = 18.dp)") &&
- sheetContent.contains("contentInsets = WindowInsets.safeContent.only(WindowInsetsSides.Bottom)")
+ "every focusable action clears the accessible target floor",
+ detail.contains("internal val NovaGameDetailActionHeight = 48.dp") &&
+ actions.contains("minHeight = NovaGameDetailActionHeight")
)
assertTrue(
- "the game-detail root must remain bounded while the detail body owns the remaining height and vertical scrolling",
- sheetContent.contains(".fillMaxHeight()") &&
- sheetContent.contains("modifier = Modifier.weight(1f)") &&
- scrollableContent.contains(".verticalScroll(scrollState)")
- )
- assertTrue(
- "the pinned Play footer must be composed after and outside the scrollable detail lambda",
- scrollBody.contains("GameDetailsPanel(") &&
- scrollBody.contains("LaunchControlsPanel(") &&
- scrollBody.contains("NovaControllerHintBar(") &&
- !scrollBody.contains("NovaGameDetailLaunchFooter(") &&
- sheetContent.indexOf("NovaGameDetailLaunchFooter(") >
- sheetContent.indexOf(scrollBody) + scrollBody.length
+ "the rail is gated rather than decorative: Launch mode appears only when there is a real choice, and a review replaces the rail with its own answers",
+ actions.contains("if (showLaunchModeAction)") &&
+ actions.contains("if (reviewExpanded)") &&
+ actions.contains("onRetryHighFps") &&
+ actions.contains("onResetProfile")
)
assertFalse(
- "the primary launch button must not remain inside the vertically scrolling launch-controls body",
- launchControls.contains("text = playLabel") ||
- launchControls.contains("onClick = onPrimaryLaunch")
- )
- assertTrue(
- "game detail should keep host/render limits in the scrolling body above the pinned launch action",
- launchControls.contains("LaunchProfilePrimaryNotice(") &&
- sheetContent.indexOf("LaunchControlsPanel(") < sheetContent.indexOf("NovaGameDetailLaunchFooter(")
- )
- assertTrue(
- "Heads up should explain evidence and the recommended next launch instead of showing one vague limited-by line",
- launchControls.contains("text = summary.noticeDetail") &&
- launchControls.contains("text = summary.noticeRecommendation")
- )
- assertTrue(
- "Heads up should remain visible when only the new evidence or recommendation fields are available",
- launchControls.contains("val hasNoticeContent = listOf(") &&
- launchControls.contains("summary.noticeDetail") &&
- launchControls.contains("summary.noticeRecommendation") &&
- launchControls.contains("if (!hasNoticeContent) return") &&
- launchControls.contains("text = notice.ifBlank { \"Launch profile adjusted\" }")
- )
- assertTrue(
- "near-target performance should use an explicit healthy status tone instead of warning styling",
- launchControls.contains("summary.noticeTone == NovaLaunchProfileNoticeTone.HEALTHY") &&
- launchControls.contains("val badgeLabel = if (isHealthy) summary.noticeLabel else \"Heads up\"") &&
- launchControls.contains("colorResource(R.color.nova_success)")
- )
- assertTrue(
- "Heads up should stay compact until the player opens the DPAD-friendly detail control",
- launchControls.contains("var noticeExpanded by remember(") &&
- launchControls.contains("text = if (noticeExpanded) \"Hide details\" else \"More details\"") &&
- launchControls.contains("stateDescription = if (noticeExpanded) \"Expanded\" else \"Collapsed\"") &&
- launchControls.contains("if (noticeExpanded && summary.noticeDetail.isNotBlank())") &&
- launchControls.contains("if (noticeExpanded && summary.noticeRecommendation.isNotBlank())")
+ "the primary launch button must not sit inside a scrolling body",
+ actions.contains("verticalScroll")
)
}
@@ -1239,52 +1184,32 @@ class NovaComposeSourceGuardTest {
@Test
fun gameDetailUsesHeroBackdropLogoTransformIconIdentityAndPosterFallback() {
val source = readNovaGameDetail()
- val sheetContent = source.section(
- "fun NovaGameDetailContent(",
- "@Composable\nprivate fun NovaGameDetailScrollableContent("
+ val overview = source.section(
+ "internal fun NovaGameDetailOverview(",
+ "private fun NovaGameDetailTitle("
)
- val detailsPanel = source.section(
- "private fun GameDetailsPanel(",
- "@Composable\nprivate fun LaunchControlsPanel("
+ val title = source.section(
+ "private fun NovaGameDetailTitle(",
+ "private fun NovaGameDetailStatusLine("
)
assertTrue(
- "detail content should route revision-aware Hero, Logo, and Icon presentation into the identity panel",
- sheetContent.contains("heroAvailable = heroAvailable") &&
- sheetContent.contains("heroPresentationKey = heroPresentationKey") &&
- sheetContent.contains("heroLoader = heroLoader") &&
- sheetContent.contains("logoPresentationKey = logoPresentationKey") &&
- sheetContent.contains("iconPresentationKey = iconPresentationKey")
- )
- assertTrue(
- "a real manifest Hero should become the compact detail backdrop",
- detailsPanel.contains("if (heroAvailable)") &&
- detailsPanel.contains("NovaGameDetailHero(") &&
- detailsPanel.contains("key(heroPresentationKey)") &&
- detailsPanel.contains("heroLoader(this)") &&
- detailsPanel.contains(".height(136.dp)")
- )
- assertTrue(
- "the manifest Logo should be revision-aware and use the saved normalized transform over Hero",
- detailsPanel.contains("BoxWithConstraints(") &&
- detailsPanel.contains("key(logoPresentationKey)") &&
- detailsPanel.contains("logoLoader(this)") &&
- detailsPanel.contains("offset(x = logoOffsetX, y = logoOffsetY)") &&
- detailsPanel.contains("scaleX = artworkState.logoScale") &&
- detailsPanel.contains("scaleY = artworkState.logoScale")
+ "the hero should be the full-bleed backdrop rather than a 136dp panel thumbnail, reusing the library's own backdrop so hero-to-poster fallback and the theme scrims come with it",
+ overview.contains("NovaLibraryCinematicBackdrop(") &&
+ overview.contains("strength = 1f") &&
+ !overview.contains(".height(136.dp)")
)
assertTrue(
- "the manifest Icon should provide a compact revision-aware identity mark beside the title",
- detailsPanel.contains("if (iconAvailable)") &&
- detailsPanel.contains("key(iconPresentationKey)") &&
- detailsPanel.contains("iconLoader(this)") &&
- detailsPanel.contains("text = game.name")
+ "curated logo artwork should become the title treatment at real size, still keyed by presentation revision",
+ title.contains("if (logoAvailable)") &&
+ title.contains("key(logoPresentationKey)") &&
+ title.contains("logoLoader(this)") &&
+ title.contains("maxWidth = 200.dp, maxHeight = 64.dp")
)
assertTrue(
- "Poster must remain the no-Hero fallback instead of disappearing from detail presentation",
- detailsPanel.contains("NovaGameDetailPosterFallback(") &&
- detailsPanel.contains("key(PolarisApiClient.artworkPresentationKey(game, PolarisGame.ARTWORK_KIND_POSTER))") &&
- detailsPanel.contains("coverLoader(this)")
+ "a game with no curated logo falls back to its name, and the fallback is a title rather than a poster card",
+ title.contains("text = game.name") &&
+ title.contains("nova-game-detail-title")
)
}
@@ -1295,11 +1220,11 @@ class NovaComposeSourceGuardTest {
"fun NovaGameDetailContent(",
"@Composable\nprivate fun NovaGameDetailScrollableContent("
)
- val artworkIndex = content.indexOf("NovaArtworkStudio(")
- val stabilityIndex = content.indexOf("optimizationState.stability?.let")
- val hintIndex = content.indexOf("NovaControllerHintBar(")
- assertTrue("artwork preferences should follow functional and insight content", artworkIndex > stabilityIndex)
- assertTrue("artwork preferences should remain above the controller hint footer", artworkIndex in 1 until hintIndex)
+ assertTrue(
+ "artwork curation should be its own destination, and a full-screen one: the studio lays itself out as a Row of weighted Columns and cannot fold into a side panel",
+ content.contains("NovaGameDetailDestination.ARTWORK -> NovaGameDetailFullScreen(") &&
+ content.contains("NovaArtworkStudio(")
+ )
val panel = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt")
assertTrue("artwork preferences should start collapsed", panel.contains("var expanded by remember(initialQuery) { mutableStateOf(false) }"))
@@ -1751,11 +1676,10 @@ class NovaComposeSourceGuardTest {
libraryScreen.contains("controllerHintBarLandscapeStartPadding")
)
assertTrue(
- "game detail sheet should keep the shared hint bar with explicit horizontal and bottom padding inside the scrollable sheet",
- detailContent.contains("NovaControllerHintBar(") &&
- detailContent.contains("hints = novaGameDetailControllerHints()") &&
- detailContent.contains(".padding(start = 14.dp, end = 14.dp, top = 12.dp)") &&
- detailContent.contains(".padding(bottom = 16.dp)")
+ "the game detail window should keep the shared hint bar on the Overview floor and in every destination",
+ detail.contains("NovaControllerHintBar(") &&
+ detail.contains("hints = novaGameDetailOverviewHints()") &&
+ detail.contains("nova_controller_hint_back")
)
assertTrue(
"settings should keep the main rows weighted above the shared hint bar instead of letting rows consume and clip the bottom controls",
@@ -2749,7 +2673,9 @@ class NovaComposeSourceGuardTest {
private fun readNovaGameDetail(): String =
readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
private fun readNovaSettingsScreen(): String =
readSource("src/main/java/com/papi/nova/preferences/NovaSettingsScreen.kt")
@@ -2772,7 +2698,9 @@ class NovaComposeSourceGuardTest {
@Test
fun gameDetailLaunchOptionsAvoidRawAppCompatAlertDialogButtons() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val launchOptions = detail.section(
"private fun showLaunchOptions(",
"private fun optionLabel("
@@ -2789,7 +2717,9 @@ class NovaComposeSourceGuardTest {
@Test
fun gameDetailProfilePreferenceAvoidsRawAppCompatAlertDialogButtons() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val profileOptions = detail.section(
"private fun showProfilePreferenceOptions(",
"private fun steamLaunchModeOptionsState("
diff --git a/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt
index 3188ef34..31fb260c 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt
@@ -10,14 +10,15 @@ class NovaLaunchSourceGuardTest {
@Test
fun gameDetailLaunchUsesSelectedMangoHudState() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
- val primaryLaunch = detail.section("onPrimaryLaunch = {", "},\n onLaunchModeSelected")
- val launchModeSelection = detail.section("fun selectLaunchMode(", "setContentView(")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt")
+ // launchConfirmed is shared by the primary action, the option picker and the
+ // expanded review, so the MangoHUD state is asserted where they all pass through.
+ val launchConfirmed = detail.section("fun launchConfirmed(", "fun resetProfile(")
+ val launchModeSelection = detail.section("fun selectLaunchMode(", "fun launchConfirmed(")
assertTrue(
- "primary Play should pass the selected MangoHUD state into the launch request",
- primaryLaunch.contains("currentGame.copy(mangohud = mangoHudEnabled)")
+ "every launch path should pass the selected MangoHUD state into the launch request",
+ launchConfirmed.contains("currentGame.copy(mangohud = mangoHudEnabled)")
)
assertTrue(
"inline mode selection should keep the selected MangoHUD state in preview/preflight state",
@@ -45,38 +46,40 @@ class NovaLaunchSourceGuardTest {
@Test
fun desktopSteamDecisionSheetUsesNovaGlassAndExplicitMirrorDesktopPlumbing() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val serverHelper = readSource("src/main/java/com/papi/nova/utils/ServerHelper.kt")
val game = readSource("src/main/java/com/papi/nova/Game.kt")
val streamConfiguration = readSource("src/main/java/com/papi/nova/nvstream/StreamConfiguration.kt")
val nvHttp = readSource("src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt")
- val chrome = readSource("src/main/java/com/papi/nova/ui/NovaSheetChrome.kt")
- val decisionSheet = detail.section(
- "private fun showDesktopSteamLaunchDecision(",
- "private fun modeLabel("
- )
- val launchOptions = detail.section(
- "onLaunchOptionSelected = { option ->",
- "onDismissLaunchOptions ="
+ val decisionRows = detail.section(
+ "internal fun NovaDesktopSteamLaunchDecisionRows(",
+ "private fun NovaSteamChoiceRow("
)
assertTrue(
- "desktop Steam active policy should open a Nova-themed Compose bottom sheet, not a legacy square AlertDialog",
- decisionSheet.contains("BottomSheetDialog(this@NovaGameDetailActivity)") &&
- decisionSheet.contains("NovaDesktopSteamLaunchDecisionContent(") &&
- !decisionSheet.contains("AlertDialog.Builder")
+ "the desktop Steam choice belongs in the Launch mode destination, not a sheet or an alert raised over the artwork",
+ detail.contains("destination = NovaGameDetailDestination.LAUNCH_MODE") &&
+ detail.contains("steamDecision = desktopSteamDecision") &&
+ !detail.contains("BottomSheetDialog(") &&
+ !detail.contains("AlertDialog.Builder")
+ )
+ assertTrue(
+ "the decision should offer explicit Private Stream, Mirror Desktop, and close-Steam paths",
+ decisionRows.contains("nova_desktop_steam_private_stream") &&
+ decisionRows.contains("nova_desktop_steam_mirror_desktop") &&
+ decisionRows.contains("NovaSteamLaunchChoice.CLOSE_STEAM_THEN_PRIVATE")
)
assertTrue(
- "decision sheet should offer explicit Private Stream, Mirror Desktop, and Cancel actions",
- decisionSheet.contains("nova_desktop_steam_private_stream") &&
- decisionSheet.contains("nova_desktop_steam_mirror_desktop") &&
- decisionSheet.contains("nova_desktop_steam_cancel")
+ "a blocked option stays visible and inert, because the reason it is blocked is the useful part",
+ decisionRows.contains("enabled = decision.privateStreamEnabled") &&
+ decisionRows.contains("caption = decision.privateStreamUnavailableReason")
)
assertTrue(
"Launch Options must not bypass desktop-Steam safety for selected private headless launches",
- launchOptions.contains("usesVirtualDisplay = option.usesVirtualDisplay") &&
- launchOptions.contains("showDesktopSteamLaunchDecision(") &&
- launchOptions.contains("onForcePrivateAfterSteamClose = { launchSelected(mirrorDesktop = false, forcePrivateAfterSteamClose = true) }")
+ detail.contains("usesVirtualDisplay = option.usesVirtualDisplay") &&
+ detail.contains("steamDecision = desktopSteamDecision")
)
assertTrue(
"Mirror Desktop must be carried as an explicit launch override through the stream launch path",
@@ -88,15 +91,16 @@ class NovaLaunchSourceGuardTest {
streamConfiguration.contains("fun setMirrorDesktop(enable: Boolean)") &&
streamConfiguration.contains("fun setForcePrivateAfterSteamClose(enable: Boolean)") &&
nvHttp.contains("&mirrorDesktop=") &&
- nvHttp.contains("&launchMode=mirror_desktop") &&
- serverHelper.contains("forcePrivateAfterSteamClose = forcePrivateAfterSteamClose")
+ nvHttp.contains("&launchMode=mirror_desktop")
)
}
@Test
fun launchFailureAndDesktopSteamActionsUseNovaThemedFlow() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val serverHelper = readSource("src/main/java/com/papi/nova/utils/ServerHelper.kt")
val game = readSource("src/main/java/com/papi/nova/Game.kt")
val nvHttp = readSource("src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt")
@@ -161,15 +165,22 @@ class NovaLaunchSourceGuardTest {
@Test
fun composeBottomSheetsUseThemeAwareGlassHostInsteadOfStaticOldThemeInset() {
- val gameDetailSheet = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ val gameDetail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val syncSheet = readSource("src/main/java/com/papi/nova/ui/NovaPolarisSyncSheet.kt")
assertTrue(
- "The desktop Steam sheet hosted by the game detail window must clear/style the Material host with shared Nova glass chrome so bottom/nav inset gaps do not show old static blue chrome",
- gameDetailSheet.contains("NovaSheetChrome.applyBottomSheetChrome(bottomSheetDialog, contentView)") &&
- gameDetailSheet.contains("NovaSheetChrome.createSheetBackground(this@NovaGameDetailActivity)") &&
- !gameDetailSheet.contains("sheet.setBackgroundResource(sheetBackgroundRes())")
+ "the game detail window hosts no bottom sheet and no legacy alert at all, so there is no Material host left to restyle",
+ !gameDetail.contains("BottomSheetDialog(") &&
+ !gameDetail.contains("AlertDialog.Builder") &&
+ !gameDetail.contains("sheet.setBackgroundResource")
+ )
+ assertTrue(
+ "a destination is a surface, not media: its glass must sit on an opaque ground or the artwork reads straight through it",
+ gameDetail.contains(".background(colors.window)\n .background(surfaces.panel)") ||
+ gameDetail.contains(".background(colors.window)\n .background(surfaces.panel)")
)
assertTrue(
"Polaris sync sheet must use the same theme-aware host chrome instead of static nova_sheet_bg inset background",
@@ -181,7 +192,9 @@ class NovaLaunchSourceGuardTest {
@Test
fun virtualLaunchPreflightUsesHostVirtualDisplayContractConstants() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val trampoline = readSource("src/main/java/com/papi/nova/ShortcutTrampoline.kt")
val displayMode = readSource("src/main/java/com/papi/nova/api/PolarisStreamDisplayMode.kt")
@@ -343,7 +356,9 @@ class NovaLaunchSourceGuardTest {
@Test
fun displayPlannerAndPostSessionReportStayControllerFirstAndLowNoise() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val quickContent = readSource("src/main/java/com/papi/nova/ui/NovaQuickMenuContent.kt")
val planner = readSource("src/main/java/com/papi/nova/ui/NovaDisplayResolutionPlanner.kt")
val optionSheet = detail.section(
@@ -489,7 +504,9 @@ class NovaLaunchSourceGuardTest {
@Test
fun virtualDisplayUnavailableCopyUsesHostVirtualDisplayLanguageAndReason() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val strings = readSource("src/main/res/values/strings.xml")
assertTrue(strings.contains("nova_library_virtual_display_unavailable_title") && strings.contains("Host Virtual Display is not ready"))
@@ -625,11 +642,10 @@ class NovaLaunchSourceGuardTest {
@Test
fun gameDetailPreflightPreservesExplicitPolarisNonVirtualMode() {
- val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt")
val preflight = detail.section(
"private fun syncLaunchPreflightSettings(",
- "private fun showPreflightReview("
+ "private fun modeLabel("
)
assertTrue(
@@ -659,7 +675,9 @@ class NovaLaunchSourceGuardTest {
@Test
fun steamLaunchSelectionDoesNotDismissGameDetailOrStartStream() {
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
val selection = detail.section(
"onSteamLaunchModeSelected = { selected ->",
"},\n onDismissSteamLaunchModeOptions"
@@ -674,7 +692,9 @@ class NovaLaunchSourceGuardTest {
fun steamLaunchModeUpdateConfirmsHostModeAndStaysInline() {
val api = readSource("src/main/java/com/papi/nova/api/PolarisApiClient.kt")
val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") +
- readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt")
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") +
+ readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt")
assertTrue(api.contains("fun setSteamLaunchMode(gameId: String, mode: String): String?"))
assertTrue(api.contains("json.optString(\"mode\", normalizedMode)"))
diff --git a/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt b/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt
index 78c82273..44cb4a3f 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt
@@ -184,7 +184,9 @@ class NovaThemeResourcesTest {
val pcView = File("src/main/java/com/papi/nova/PcView.kt").readText()
val appView = File("src/main/java/com/papi/nova/AppView.kt").readText()
val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt").readText() +
- File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText()
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt").readText()
val polarisSync = File("src/main/java/com/papi/nova/ui/NovaPolarisSyncSheet.kt").readText()
val library = File("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt").readText()
val contextSheet = File("src/main/res/layout/nova_app_context_sheet.xml").readText()
@@ -257,7 +259,9 @@ class NovaThemeResourcesTest {
val sheetChrome = File("src/main/java/com/papi/nova/ui/NovaSheetChrome.kt").readText()
val composeTheme = File("src/main/java/com/papi/nova/ui/compose/NovaComposeTheme.kt").readText()
val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt").readText() +
- File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText()
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt").readText()
assertTrue("native sheet chrome must expose a named glass alpha contract", sheetChrome.contains("SHEET_GLASS_ALPHA"))
assertTrue("native sheet backgrounds should preserve theme color while applying the absolute outer opacity", sheetChrome.contains("ColorUtils.setAlphaComponent") && sheetChrome.contains("NovaMenuPreferences.outerSurfaceAlpha"))
@@ -390,7 +394,9 @@ class NovaThemeResourcesTest {
val lifecycle = File("src/main/java/com/papi/nova/ui/NovaStreamOverlayContent.kt").readText()
val library = File("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt").readText()
val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt").readText() +
- File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText()
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt").readText()
val settings = File("src/main/java/com/papi/nova/preferences/NovaSettingsScreen.kt").readText()
val focusComponents = File("src/main/java/com/papi/nova/ui/compose/NovaFocusComponents.kt").readText()
val settingsViewModel = File("src/main/java/com/papi/nova/preferences/NovaSettingsViewModel.kt").readText()
@@ -453,14 +459,19 @@ class NovaThemeResourcesTest {
@Test
fun requiredNativeAlertsUseSharedOpacityAndBlurChrome() {
val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt").readText() +
- File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText()
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt").readText()
val legacySlider = File("src/main/java/com/papi/nova/preferences/SeekBarPreference.kt").readText()
val sessionDialog = File("src/main/java/com/papi/nova/utils/Dialog.kt").readText()
val preflight = gameDetail
.substringAfter("private fun showPreflightReview(")
.substringBefore("private fun showDesktopSteamLaunchDecision(")
- assertTrue("game-detail preflight should use shared literal-opacity alert chrome", preflight.contains("NovaSheetChrome.applyMenuOpacityToLegacyAlert"))
+ assertTrue(
+ "the game detail window raises no legacy alert at all: the preflight review expands the status line in place instead",
+ !preflight.contains("AlertDialog.Builder") && preflight.contains("reviewExpanded")
+ )
assertTrue("legacy sliders, including Menu & Drawer Opacity, should use shared literal-opacity alert chrome", legacySlider.contains("NovaSheetChrome.applyMenuOpacityToLegacyAlert(createdDialog)"))
assertTrue("session termination/error alerts should use shared literal-opacity alert chrome", sessionDialog.contains("NovaSheetChrome.applyMenuOpacityToLegacyAlert(createdAlert)"))
}
@@ -547,7 +558,9 @@ class NovaThemeResourcesTest {
val pcView = File("src/main/java/com/papi/nova/PcView.kt").readText()
val appView = File("src/main/java/com/papi/nova/AppView.kt").readText()
val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt").readText() +
- File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText()
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() +
+ File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt").readText()
val manager = File("src/main/java/com/papi/nova/ui/NovaThemeManager.kt").readText()
val particles = File("src/main/java/com/papi/nova/ui/SpaceParticleView.kt").readText()
From ebdb0f6e25ca6d9e9fa448578b82d236815f8a51 Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Wed, 5 Aug 2026 18:21:49 -0400
Subject: [PATCH 02/24] feat(nova): paint the detail overview the way the
concept specifies
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The first pass reached for NovaActionButton and NovaControllerHintBar because they
carry focus handling, theming and accessibility for free. They also carry the
library's visual language, so the screen came out structurally right and visually
wrong: a flat accent fill with no button glyphs, sentence-case status text, and a
bordered hint container sitting on the artwork.
The identity and status lines are instrument readouts now — uppercase, tracked, and
with tabular figures, since those are measurements and the digits should line up
rather than dance. The primary action carries the button it is bound to on an accent
gradient; the rest are quiet, hairline-bordered and marked. The floor is borderless
with the Polaris mark opposite the back hint, because a bordered container would be
one more box on a screen whose point is that nothing sits on the artwork in a box.
Focus stays a ring and a tint, never a scale or an offset.
Two guards follow the repaint: the Overview paints the shared hint model itself
rather than using the bordered bar, and the action lane is its own composable.
---
.../papi/nova/ui/NovaGameDetailOverview.kt | 212 ++++++++++++++----
app/src/main/res/values/strings.xml | 1 +
.../nova/ui/NovaComposeSourceGuardTest.kt | 11 +-
3 files changed, 180 insertions(+), 44 deletions(-)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
index 6e68c2f0..4efa05c7 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
@@ -2,17 +2,21 @@ package com.papi.nova.ui
import android.widget.ImageView
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.border
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.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContent
@@ -21,8 +25,13 @@ import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.runtime.key
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.Alignment
@@ -30,8 +39,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -114,7 +126,7 @@ internal fun NovaGameDetailOverview(
)
Text(
- text = novaGameDetailIdentityLine(sourceLabel, lastPlayedText, game),
+ text = novaGameDetailIdentityLine(sourceLabel, lastPlayedText, game).uppercase(),
color = colors.textSecondary,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
@@ -169,16 +181,53 @@ internal fun NovaGameDetailOverview(
modifier = Modifier.padding(top = 16.dp),
)
- NovaControllerHintBar(
- hints = novaGameDetailOverviewHints(),
- compact = true,
- modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
- )
+ NovaGameDetailFooter(modifier = Modifier.fillMaxWidth().padding(top = 14.dp))
}
}
}
+/**
+ * The floor: what the buttons do on the left, the Polaris mark on the right. Borderless,
+ * because a bordered container here would be one more box on a screen whose point is that
+ * nothing sits on the artwork in a box.
+ */
+@Composable
+private fun NovaGameDetailFooter(modifier: Modifier = Modifier) {
+ val colors = LocalNovaComposeColors.current
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier.testTag("nova-game-detail-footer"),
+ ) {
+ novaGameDetailOverviewHints().forEach { hint ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier.padding(end = 18.dp),
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(20.dp)
+ .clip(RoundedCornerShape(percent = 50))
+ .background(colors.accent.copy(alpha = 0.22f)),
+ ) {
+ Text(hint.key, color = colors.textPrimary, fontSize = 9.sp, fontWeight = FontWeight.Bold)
+ }
+ Text(hint.label, color = colors.textSecondary, fontSize = 11.sp)
+ }
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ Text(
+ text = stringResource(R.string.nova_polaris_wordmark),
+ color = colors.textMuted,
+ fontSize = 9.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 0.20.em,
+ )
+ }
+}
+
/**
* Curated logo artwork replaces the title outright when it is ready at first composition.
* A logo that arrives later is ignored: swapping a settled title for one is a visible jump,
@@ -249,11 +298,15 @@ private fun NovaGameDetailStatusLine(
) {
Box(modifier = Modifier.size(7.dp).clip(RoundedCornerShape(percent = 50)).background(lamp))
Text(
- text = novaGameDetailStatusText(uiState, summary),
+ text = novaGameDetailStatusText(uiState, summary).uppercase(),
color = colors.textPrimary,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.11.em,
+ // these are measurements, so the digits line up rather than dance
+ style = LocalTextStyle.current.copy(
+ fontFeatureSettings = "tnum",
+ ),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -284,15 +337,12 @@ private fun NovaGameDetailActions(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
- NovaActionButton(
+ NovaGameDetailAction(
text = playLabel,
onClick = onPrimaryLaunch,
enabled = uiState.playEnabled,
primary = true,
- minHeight = NovaGameDetailActionHeight,
- cornerRadius = NovaGameDetailCornerRadius,
- fontSize = 15.sp,
- contentPadding = PaddingValues(horizontal = 22.dp, vertical = 12.dp),
+ glyph = stringResource(R.string.nova_controller_hint_a),
modifier = Modifier
.focusRequester(playFocusRequester)
.testTag("nova-game-detail-primary"),
@@ -300,45 +350,129 @@ private fun NovaGameDetailActions(
if (reviewExpanded) {
if (optimizationState.profileSummary?.showRetryHighFps == true) {
- NovaGameDetailSecondaryAction(stringResource(R.string.nova_library_retry_high_fps), onRetryHighFps)
+ NovaGameDetailAction(
+ text = stringResource(R.string.nova_library_retry_high_fps),
+ onClick = onRetryHighFps,
+ mark = "\u25B2",
+ )
}
- NovaGameDetailSecondaryAction(stringResource(R.string.nova_library_reset_game_profile), onResetProfile)
+ NovaGameDetailAction(
+ text = stringResource(R.string.nova_library_reset_game_profile),
+ onClick = onResetProfile,
+ mark = "\u21BA",
+ )
} else {
if (showLaunchModeAction) {
- NovaGameDetailSecondaryAction(stringResource(R.string.nova_library_launch_mode_title)) {
- onDestination(NovaGameDetailDestination.LAUNCH_MODE)
- }
- }
- NovaGameDetailSecondaryAction(stringResource(R.string.nova_library_launch_options_secondary)) {
- onDestination(NovaGameDetailDestination.TUNE)
- }
- NovaGameDetailSecondaryAction(stringResource(R.string.nova_artwork_studio_title)) {
- onDestination(NovaGameDetailDestination.ARTWORK)
+ NovaGameDetailAction(
+ text = stringResource(R.string.nova_library_launch_mode_title),
+ onClick = { onDestination(NovaGameDetailDestination.LAUNCH_MODE) },
+ mark = "\u229E",
+ )
}
+ NovaGameDetailAction(
+ text = stringResource(R.string.nova_library_launch_options_secondary),
+ onClick = { onDestination(NovaGameDetailDestination.TUNE) },
+ mark = "\u2699",
+ )
+ NovaGameDetailAction(
+ text = stringResource(R.string.nova_artwork_studio_title),
+ onClick = { onDestination(NovaGameDetailDestination.ARTWORK) },
+ mark = "\u25C8",
+ )
}
}
}
+/**
+ * One action in the lane. The primary carries the button it is bound to and an accent
+ * gradient; the rest are quiet, hairline-bordered and marked. Focus is a ring and a
+ * tint — never a scale or an offset, which is the contract the poster cards settled on.
+ */
@Composable
-private fun NovaGameDetailSecondaryAction(text: String, onClick: () -> Unit) {
+private fun NovaGameDetailAction(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ primary: Boolean = false,
+ glyph: String? = null,
+ mark: String? = null,
+) {
+ val colors = LocalNovaComposeColors.current
val surfaces = LocalNovaLibrarySurfaces.current
- NovaActionButton(
- text = text,
- onClick = onClick,
- minHeight = NovaGameDetailActionHeight,
- cornerRadius = NovaGameDetailCornerRadius,
- fontSize = 13.sp,
- contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
- modifier = Modifier
- .clip(RoundedCornerShape(NovaGameDetailCornerRadius))
- .background(surfaces.control.copy(alpha = 1f)),
- )
+ val interactionSource = remember { MutableInteractionSource() }
+ var focused by remember { mutableStateOf(false) }
+ val shape = RoundedCornerShape(NovaGameDetailCornerRadius)
+
+ val background = if (primary && enabled) {
+ Brush.linearGradient(
+ listOf(
+ colors.accent,
+ lerp(colors.accent, Color.White, 0.28f),
+ lerp(colors.accent, Color.White, 0.62f),
+ ),
+ )
+ } else {
+ SolidColor(surfaces.control.copy(alpha = 1f))
+ }
+ val label = when {
+ primary && enabled -> colors.onAccent
+ enabled -> colors.textPrimary
+ else -> colors.textMuted
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(9.dp),
+ modifier = modifier
+ .heightIn(min = NovaGameDetailActionHeight)
+ .clip(shape)
+ .background(background, shape)
+ .border(
+ width = if (focused) 2.dp else 1.dp,
+ color = if (focused) colors.accent else surfaces.tileBorder,
+ shape = shape,
+ )
+ .onFocusChanged { focused = it.isFocused }
+ .clickable(
+ enabled = enabled,
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = onClick,
+ )
+ .semantics { contentDescription = text }
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ ) {
+ if (glyph != null) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(20.dp)
+ .clip(RoundedCornerShape(percent = 50))
+ .background(colors.window.copy(alpha = 0.88f)),
+ ) {
+ Text(
+ text = glyph,
+ color = colors.textPrimary,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+ if (mark != null) {
+ Text(text = mark, color = label.copy(alpha = 0.62f), fontSize = 13.sp)
+ }
+ Text(
+ text = text,
+ color = label,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
}
-/**
- * The alert this replaces stated the reason and offered launch, retry and reset. Expanded
- * in place it keeps all four, without a dialog landing on the artwork.
- */
@Composable
private fun LaunchProfileReviewNotice(
optimizationState: NovaGameDetailOptimizationState,
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 151b520e..ce38b6c1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -262,6 +262,7 @@
Version and client identity.
%1$s
System
+ ✦ POLARIS
A
B
X
diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
index 192cf7a3..1aa074f4 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
@@ -1079,7 +1079,7 @@ class NovaComposeSourceGuardTest {
)
val actions = detail.section(
"private fun NovaGameDetailActions(",
- "private fun NovaGameDetailSecondaryAction("
+ "private fun NovaGameDetailAction("
)
assertTrue(
@@ -1097,7 +1097,7 @@ class NovaComposeSourceGuardTest {
assertTrue(
"every focusable action clears the accessible target floor",
detail.contains("internal val NovaGameDetailActionHeight = 48.dp") &&
- actions.contains("minHeight = NovaGameDetailActionHeight")
+ detail.contains("heightIn(min = NovaGameDetailActionHeight)")
)
assertTrue(
"the rail is gated rather than decorative: Launch mode appears only when there is a real choice, and a review replaces the rail with its own answers",
@@ -1676,9 +1676,10 @@ class NovaComposeSourceGuardTest {
libraryScreen.contains("controllerHintBarLandscapeStartPadding")
)
assertTrue(
- "the game detail window should keep the shared hint bar on the Overview floor and in every destination",
- detail.contains("NovaControllerHintBar(") &&
- detail.contains("hints = novaGameDetailOverviewHints()") &&
+ "the game detail window keeps the shared hint model; the Overview paints it borderless on the artwork while destinations keep the reusable bar",
+ detail.contains("List") &&
+ detail.contains("novaGameDetailOverviewHints()") &&
+ detail.contains("NovaControllerHintBar(") &&
detail.contains("nova_controller_hint_back")
)
assertTrue(
From 49256201db116142ad7c290ed8562206294135ac Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Wed, 5 Aug 2026 18:33:48 -0400
Subject: [PATCH 03/24] feat(nova): give the detail destinations their own
headers and grouping
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The fidelity pass stopped at the Overview, so the destinations still carried habits
from the sheet. Launch mode's eyebrow and headline both read Launch Mode, and
Tune's headline repeated the button the user had just pressed rather than naming the
profile it is about to change.
The rail and the destination now call the same place the same thing: Tune. Launch
mode says what it is for. Tune leads with the profile and separates state you read
from actions you take, so a readout like the Steam launch mode no longer sits in the
same shape as Reset Game Profile — one is a statement, the other has consequences.
---
.../com/papi/nova/ui/NovaGameDetailContent.kt | 27 ++++++--
.../nova/ui/NovaGameDetailDestinations.kt | 62 +++++++++++++++++++
.../papi/nova/ui/NovaGameDetailOverview.kt | 2 +-
app/src/main/res/values/strings.xml | 4 ++
4 files changed, 88 insertions(+), 7 deletions(-)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
index 69c1a9bf..a344bf57 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
@@ -320,8 +320,12 @@ internal fun NovaGameDetailContent(
NovaGameDetailDestination.LAUNCH_MODE -> NovaGameDetailPanel(
eyebrow = stringResource(R.string.nova_library_launch_mode_title),
- headline = launchModeTitle,
- readout = optimizationState.profileSummary?.selectedLine.orEmpty(),
+ headline = stringResource(R.string.nova_game_detail_where_it_runs),
+ readout = if (steamDecision != null) {
+ stringResource(R.string.nova_desktop_steam_title)
+ } else {
+ optimizationState.profileSummary?.selectedLine.orEmpty()
+ },
scrollState = verticalScroll,
) {
val decision = steamDecision
@@ -362,12 +366,15 @@ internal fun NovaGameDetailContent(
}
NovaGameDetailDestination.TUNE -> NovaGameDetailPanel(
- eyebrow = stringResource(R.string.nova_library_launch_options_secondary),
- headline = optimizationState.profileSummary?.primaryLaunchLabel.orEmpty()
- .ifBlank { profilePreferenceLabel },
- readout = optimizationState.profileSummary?.freshnessLine.orEmpty(),
+ eyebrow = stringResource(R.string.nova_game_detail_tune),
+ headline = profilePreferenceLabel,
+ readout = listOf(
+ optimizationState.profileSummary?.selectedLine,
+ optimizationState.profileSummary?.freshnessLine,
+ ).filter { !it.isNullOrBlank() }.joinToString(" · "),
scrollState = verticalScroll,
) {
+ NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_state))
profileOptionsState?.let {
NovaProfilePreferenceSheet(
state = it,
@@ -399,6 +406,14 @@ internal fun NovaGameDetailContent(
}
optimizationState.ai?.let { InsightCard(card = it) }
optimizationState.stability?.let { InsightCard(card = it) }
+ NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_actions))
+ LaunchProfileSummaryActions(
+ summary = optimizationState.profileSummary,
+ resetProfileLabel = resetProfileLabel,
+ resetProfileWorking = resetProfileWorking,
+ onRetryHighFps = onRetryHighFps,
+ onResetProfile = onResetProfile,
+ )
}
// The studio opens with a Row of weighted Columns, so it needs the window
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
index dd0b82c9..dfab906d 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -172,6 +173,67 @@ private fun NovaGameDetailDestinationHeader(eyebrow: String, headline: String, r
}
}
+/**
+ * Divides what you read from what you do. The sheet presented both as one list, so a
+ * readout like "MangoHUD: On" sat in the same shape as "Reset profile" — one is a
+ * statement, the other has consequences.
+ */
+@Composable
+internal fun NovaGameDetailGroupLabel(text: String) {
+ val colors = LocalNovaComposeColors.current
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 6.dp),
+ ) {
+ Text(
+ text = text.uppercase(),
+ color = colors.textMuted,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 0.22.em,
+ )
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .height(1.dp)
+ .background(colors.divider.copy(alpha = 0.5f)),
+ )
+ }
+}
+
+/** Retry and reset: the two things in Tune that change something rather than report it. */
+@Composable
+internal fun LaunchProfileSummaryActions(
+ summary: NovaLaunchProfileSummary?,
+ resetProfileLabel: String,
+ resetProfileWorking: Boolean,
+ onRetryHighFps: () -> Unit,
+ onResetProfile: () -> Unit,
+) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ if (summary?.showRetryHighFps == true) {
+ NovaSteamChoiceRow(
+ label = summary.retryHighFpsLabel.ifBlank {
+ stringResource(R.string.nova_library_retry_high_fps)
+ },
+ caption = "",
+ enabled = true,
+ onClick = onRetryHighFps,
+ )
+ }
+ NovaSteamChoiceRow(
+ label = resetProfileLabel,
+ caption = "",
+ enabled = !resetProfileWorking,
+ onClick = onResetProfile,
+ )
+ }
+}
+
/**
* The desktop-Steam choice, as rows in Launch mode rather than a sheet over the artwork.
* A blocked option stays visible and inert: hiding it loses the reason it is blocked,
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
index 4efa05c7..41dc0a00 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
@@ -370,7 +370,7 @@ private fun NovaGameDetailActions(
)
}
NovaGameDetailAction(
- text = stringResource(R.string.nova_library_launch_options_secondary),
+ text = stringResource(R.string.nova_game_detail_tune),
onClick = { onDestination(NovaGameDetailDestination.TUNE) },
mark = "\u2699",
)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ce38b6c1..0471b43d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -262,6 +262,10 @@
Version and client identity.
%1$s
System
+ Tune
+ Where it runs
+ State
+ Actions
✦ POLARIS
A
B
From e1a4f3a287230dd0a0f7017f4d40fef8a327b73a Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Wed, 5 Aug 2026 19:12:16 -0400
Subject: [PATCH 04/24] feat(nova): let the primary action follow the session
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Pressing Play on a game that is already running is wrong twice over: it relaunches
what could have been resumed, and on someone else's session it launches over them.
The window can see the session — it holds the api client — so the verb is derived
rather than assumed: Watch when another device owns it, Resume when you do, and the
launch label otherwise. Yours also gains End session.
Resuming and ending both need stream credentials the window does not carry, so it
returns the intent and the library performs it, which is the contract the launch
already uses.
Making that reachable needed a library change. While a session is live the library
drops that game from the grid and shows it only in the resume row, whose card
resumed along with its button — so the running game was the one entry whose detail
could not be opened at all. The card now opens the game and the buttons act on it,
which is what the card already did everywhere else.
Verified against a live Control session on the Retroid: Resume and End session both
appear, and End reaches the host through the result contract and its confirmation.
---
.../papi/nova/ui/NovaGameDetailComposeTest.kt | 4 ++
.../papi/nova/ui/NovaGameDetailActivity.kt | 37 +++++++++++++++++++
.../com/papi/nova/ui/NovaGameDetailContent.kt | 6 +++
.../papi/nova/ui/NovaGameDetailOverview.kt | 31 ++++++++++++++--
.../com/papi/nova/ui/NovaLibraryActivity.kt | 25 ++++++++++++-
app/src/main/res/values/strings.xml | 3 ++
.../nova/ui/NovaComposeSourceGuardTest.kt | 8 ++--
7 files changed, 105 insertions(+), 9 deletions(-)
diff --git a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt
index 990ebdd1..70e0801c 100644
--- a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt
+++ b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt
@@ -126,6 +126,10 @@ class NovaGameDetailComposeTest {
sourceLabel = "Steam",
onDestination = {},
onSteamChoice = {},
+ // no session under test, so the primary action stays a launch
+ activeSession = null,
+ onResumeSession = {},
+ onEndSession = {},
)
}
}
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
index 1f0ea738..2a3260ba 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
@@ -153,6 +153,17 @@ class NovaGameDetailActivity : NovaActivity() {
private val onGameUpdated: ((PolarisGame) -> Unit)? = { game -> updatedGame = game }
+ /** Resume and End need stream credentials this window does not carry, so it asks. */
+ private fun finishWithSessionRequest(request: String) {
+ setResult(
+ RESULT_OK,
+ Intent()
+ .putExtra(EXTRA_RESULT_SESSION, request)
+ .putExtra(EXTRA_RESULT_GAME, updatedGame?.let { PolarisGameJson.encode(it) }),
+ )
+ finish()
+ }
+
private val onRefreshArtwork: ((PolarisGame, (NovaArtworkMutationResult) -> Unit) -> Unit)? =
{ game, onResult -> artworkViewModel.refreshArtwork(game = game, onResult = onResult) }
@@ -187,6 +198,9 @@ class NovaGameDetailActivity : NovaActivity() {
/** The preflight review, expanded on the Overview rather than raised as an alert. */
private var reviewExpanded by mutableStateOf(false)
+ /** The host's session, when it is this game's. Null means nothing is running. */
+ private var activeSession by mutableStateOf(null)
+
override fun onCreate(savedInstanceState: Bundle?) {
NovaThemeManager.applyTheme(this)
super.onCreate(savedInstanceState)
@@ -225,6 +239,7 @@ class NovaGameDetailActivity : NovaActivity() {
)
setUpDetail(game, apiClient)
+ refreshActiveSession(game)
}
/**
@@ -244,6 +259,22 @@ class NovaGameDetailActivity : NovaActivity() {
else -> false
}
+ /**
+ * Polaris reports one session at a time, so it only matters here when it is this
+ * game's. Matched on the UUID Polaris uses, falling back to the numeric app id.
+ */
+ private fun refreshActiveSession(game: PolarisGame) {
+ lifecycleScope.launch {
+ val session = withContext(Dispatchers.IO) {
+ runCatching { NovaLibraryActiveSessionUiState.from(apiClient.getSessionStatus()) }
+ .getOrNull()
+ }
+ activeSession = session?.takeIf {
+ it.gameUuid.equals(game.id, ignoreCase = true) || it.gameId == game.appId
+ }
+ }
+ }
+
/** Carries artwork or MangoHUD edits back even when the window closes without launching. */
private fun publishGameUpdate() {
val game = updatedGame ?: return
@@ -485,6 +516,9 @@ class NovaGameDetailActivity : NovaActivity() {
apiClient = apiClient,
sourceLabel = currentGame.sourceLabel,
onDestination = { next -> destination = next },
+ activeSession = activeSession,
+ onResumeSession = { finishWithSessionRequest(RESULT_SESSION_RESUME) },
+ onEndSession = { finishWithSessionRequest(RESULT_SESSION_END) },
onSteamChoice = { choice ->
when (choice) {
NovaSteamLaunchChoice.PRIVATE_STREAM ->
@@ -1335,6 +1369,9 @@ class NovaGameDetailActivity : NovaActivity() {
const val EXTRA_DEFAULT_VIRTUAL_DISPLAY = "nova.detail.defaultVirtualDisplay"
const val EXTRA_RESULT_LAUNCH = "nova.detail.result.launch"
const val EXTRA_RESULT_LAUNCH_GAME = "nova.detail.result.launchGame"
+ const val EXTRA_RESULT_SESSION = "nova.detail.result.session"
+ const val RESULT_SESSION_RESUME = "resume"
+ const val RESULT_SESSION_END = "end"
const val EXTRA_RESULT_GAME = "nova.detail.result.game"
const val RESULT_KEY_VIRTUAL_DISPLAY = "virtualDisplay"
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
index a344bf57..81323661 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
@@ -289,6 +289,9 @@ internal fun NovaGameDetailContent(
sourceLabel: String,
onDestination: (NovaGameDetailDestination) -> Unit,
onSteamChoice: (NovaSteamLaunchChoice) -> Unit,
+ activeSession: NovaLibraryActiveSessionUiState?,
+ onResumeSession: () -> Unit,
+ onEndSession: () -> Unit,
) {
val verticalScroll = rememberScrollState()
val playFocusRequester = remember { FocusRequester() }
@@ -313,6 +316,9 @@ internal fun NovaGameDetailContent(
onRetryHighFps = onRetryHighFps,
onResetProfile = onResetProfile,
onDestination = onDestination,
+ activeSession = activeSession,
+ onResumeSession = onResumeSession,
+ onEndSession = onEndSession,
)
when (destination) {
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
index 41dc0a00..3d28df46 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
@@ -102,6 +102,9 @@ internal fun NovaGameDetailOverview(
onRetryHighFps: () -> Unit,
onResetProfile: () -> Unit,
onDestination: (NovaGameDetailDestination) -> Unit,
+ activeSession: NovaLibraryActiveSessionUiState?,
+ onResumeSession: () -> Unit,
+ onEndSession: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = LocalNovaComposeColors.current
@@ -178,6 +181,9 @@ internal fun NovaGameDetailOverview(
onRetryHighFps = onRetryHighFps,
onResetProfile = onResetProfile,
onDestination = onDestination,
+ activeSession = activeSession,
+ onResumeSession = onResumeSession,
+ onEndSession = onEndSession,
modifier = Modifier.padding(top = 16.dp),
)
@@ -330,6 +336,9 @@ private fun NovaGameDetailActions(
onRetryHighFps: () -> Unit,
onResetProfile: () -> Unit,
onDestination: (NovaGameDetailDestination) -> Unit,
+ activeSession: NovaLibraryActiveSessionUiState?,
+ onResumeSession: () -> Unit,
+ onEndSession: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
@@ -337,17 +346,31 @@ private fun NovaGameDetailActions(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
+ // Precedence: someone else's session, then yours, then the ordinary launch.
+ // Launching over a session another device owns would take their display.
NovaGameDetailAction(
- text = playLabel,
- onClick = onPrimaryLaunch,
- enabled = uiState.playEnabled,
- primary = true,
+ text = when {
+ activeSession?.watchOnly == true -> stringResource(R.string.nova_game_detail_watch)
+ activeSession != null -> stringResource(R.string.nova_game_detail_resume)
+ else -> playLabel
+ },
+ onClick = if (activeSession != null) onResumeSession else onPrimaryLaunch,
+ enabled = uiState.playEnabled || activeSession != null,
+ primary = activeSession?.watchOnly != true,
glyph = stringResource(R.string.nova_controller_hint_a),
modifier = Modifier
.focusRequester(playFocusRequester)
.testTag("nova-game-detail-primary"),
)
+ if (activeSession != null && !activeSession.watchOnly) {
+ NovaGameDetailAction(
+ text = stringResource(R.string.nova_game_detail_end_session),
+ onClick = onEndSession,
+ mark = "◼",
+ )
+ }
+
if (reviewExpanded) {
if (optimizationState.profileSummary?.showRetryHighFps == true) {
NovaGameDetailAction(
diff --git a/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt
index ddcd916e..bdeb2ead 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt
@@ -712,6 +712,13 @@ class NovaLibraryActivity : NovaActivity() {
return consumed
}
+ private fun queryActiveSessionAsync(onResult: (NovaLibraryActiveSessionUiState?) -> Unit) {
+ lifecycleScope.launch {
+ val session = withContext(Dispatchers.IO) { runCatching { queryActiveSession() }.getOrNull() }
+ onResult(session)
+ }
+ }
+
private fun queryActiveSession(): NovaLibraryActiveSessionUiState? {
return NovaLibraryActiveSessionUiState.from(apiClient.getSessionStatus())
}
@@ -797,6 +804,14 @@ class NovaLibraryActivity : NovaActivity() {
data.getStringExtra(NovaGameDetailActivity.EXTRA_RESULT_GAME)
?.let { PolarisGameJson.decode(it) }
?.let { updated -> allGames = allGames.map { if (it.id == updated.id) updated else it } }
+ when (data.getStringExtra(NovaGameDetailActivity.EXTRA_RESULT_SESSION)) {
+ // The window saw the session but cannot act on it: resuming and ending both
+ // need stream credentials that live here.
+ NovaGameDetailActivity.RESULT_SESSION_RESUME ->
+ queryActiveSessionAsync { session -> session?.let { resumeActiveSession(it) } }
+ NovaGameDetailActivity.RESULT_SESSION_END ->
+ queryActiveSessionAsync { session -> session?.let { endActiveSession(it) } }
+ }
val launch = data.getStringExtra(NovaGameDetailActivity.EXTRA_RESULT_LAUNCH) ?: return
val request = try {
@@ -1287,6 +1302,7 @@ class NovaLibraryActivity : NovaActivity() {
null -> Unit
}
},
+ onOpenDetail = model.hero.game?.let { game -> { onOpenDetail(game) } },
onGameFocused = onGameFocused
)
}
@@ -1365,6 +1381,7 @@ class NovaLibraryActivity : NovaActivity() {
null -> Unit
}
},
+ onOpenDetail = model.hero.game?.let { game -> { onOpenDetail(game) } },
onGameFocused = onGameFocused
)
}
@@ -1520,6 +1537,12 @@ class NovaLibraryActivity : NovaActivity() {
apiClient: PolarisApiClient,
onPrimaryAction: () -> Unit,
onSecondaryAction: (() -> Unit)? = null,
+ /**
+ * The card opens the game; the buttons do the thing. Without this the running
+ * game was the one entry whose detail could not be reached at all, because the
+ * whole card resumed and the grid omits it while a session is live.
+ */
+ onOpenDetail: (() -> Unit)? = null,
onGameFocused: (PolarisGame) -> Unit
) {
val colors = LocalNovaComposeColors.current
@@ -1562,7 +1585,7 @@ class NovaLibraryActivity : NovaActivity() {
.onFocusChanged {
focused = it.isFocused || it.hasFocus
}
- .combinedClickable(onClick = onPrimaryAction)
+ .combinedClickable(onClick = onOpenDetail ?: onPrimaryAction)
.focusable()
.padding(if (compact) 8.dp else 16.dp),
horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 16.dp),
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0471b43d..f59c49d2 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -262,6 +262,9 @@
Version and client identity.
%1$s
System
+ Resume
+ Watch
+ End session
Tune
Where it runs
State
diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
index 1aa074f4..85ace9e7 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
@@ -575,9 +575,9 @@ class NovaComposeSourceGuardTest {
hero.contains("if (!compact && hero.badges.isNotEmpty())")
)
assertTrue(
- "hero card itself should activate the same primary action when D-pad focus lands on the container",
- hero.contains(".combinedClickable(onClick = onPrimaryAction)") &&
- hero.indexOf(".combinedClickable(onClick = onPrimaryAction)") in 0 until hero.indexOf(".focusable()")
+ "hero card opens the game and falls back to the primary action, so a running game is still reachable while the grid omits it",
+ hero.contains(".combinedClickable(onClick = onOpenDetail ?: onPrimaryAction)") &&
+ hero.indexOf(".combinedClickable(onClick = onOpenDetail ?: onPrimaryAction)") in 0 until hero.indexOf(".focusable()")
)
assertTrue(
"hero focus should update the focused backdrop/focus restore model for D-pad users",
@@ -1086,7 +1086,7 @@ class NovaComposeSourceGuardTest {
"the primary action holds first focus, and nothing scrolls above it",
detail.contains("val playFocusRequester = remember { FocusRequester() }") &&
actions.contains(".focusRequester(playFocusRequester)") &&
- actions.contains("primary = true") &&
+ actions.contains("primary = activeSession?.watchOnly != true") &&
!overview.contains("verticalScroll")
)
assertTrue(
From 03b17acc44608291d8b1e917ff8efcfd7fc22e0b Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Wed, 5 Aug 2026 20:26:38 -0400
Subject: [PATCH 05/24] feat(nova): give the detail overview a portrait layout
Anchoring the content bottom-left is a landscape assumption. On a phone it stranded
everything at the floor of a very tall screen, and it cropped a 3:1 Steam hero into a
0.45:1 viewport, which showed a narrow vertical slice of hair rather than a subject.
Portrait now puts the hero in a 16:9 band that dissolves into the window colour, sets
the content directly beneath it, stacks the actions at a shared full width so labels
stop truncating, and pins the floor to the bottom of the screen instead of trailing
the buttons.
Verified on a Pixel 10 Pro under Material You, which also covers the theme whose
accent is arbitrary because it comes from the wallpaper. The Retroid could not test
any of this: its panel is hardware-mounted landscape and ignores rotation.
The band leaves empty ground beneath the actions. That is where the playtime and How
Long To Beat gauge goes once Polaris can supply it; filler would be worse.
---
.../papi/nova/ui/NovaGameDetailOverview.kt | 91 +++++++++++++++++--
1 file changed, 81 insertions(+), 10 deletions(-)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
index 3d28df46..df184599 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
@@ -7,12 +7,14 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
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.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -37,6 +39,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
@@ -110,13 +113,31 @@ internal fun NovaGameDetailOverview(
val colors = LocalNovaComposeColors.current
val game = uiState.game
- Box(modifier = modifier.fillMaxSize().testTag("nova-game-detail-overview")) {
- NovaLibraryCinematicBackdrop(game = game, apiClient = apiClient, strength = 1f)
+ BoxWithConstraints(modifier = modifier.fillMaxSize().testTag("nova-game-detail-overview")) {
+ val portrait = maxHeight > maxWidth
+
+ if (portrait) {
+ // A 3:1 hero cropped to a phone's aspect shows a slice, not a subject, so it
+ // gets a 16:9 band and dissolves into the ground rather than filling behind.
+ NovaLibraryCinematicBackdrop(
+ game = game,
+ apiClient = apiClient,
+ strength = 1f,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .fillMaxWidth()
+ .aspectRatio(16f / 9f)
+ .novaFadeToGround(colors.window),
+ )
+ } else {
+ NovaLibraryCinematicBackdrop(game = game, apiClient = apiClient, strength = 1f)
+ }
Column(
modifier = Modifier
- .align(Alignment.BottomStart)
+ .align(if (portrait) Alignment.TopStart else Alignment.BottomStart)
.fillMaxWidth()
+ .then(if (portrait) Modifier.padding(top = 176.dp) else Modifier)
.windowInsetsPadding(WindowInsets.safeContent)
.padding(start = NovaGameDetailInset, end = NovaGameDetailInset, bottom = 10.dp),
) {
@@ -171,6 +192,7 @@ internal fun NovaGameDetailOverview(
}
NovaGameDetailActions(
+ stacked = portrait,
uiState = uiState,
optimizationState = optimizationState,
playLabel = playLabel,
@@ -187,7 +209,20 @@ internal fun NovaGameDetailOverview(
modifier = Modifier.padding(top = 16.dp),
)
- NovaGameDetailFooter(modifier = Modifier.fillMaxWidth().padding(top = 14.dp))
+ if (!portrait) {
+ NovaGameDetailFooter(modifier = Modifier.fillMaxWidth().padding(top = 14.dp))
+ }
+ }
+
+ // On a tall screen the floor belongs at the bottom, not trailing the actions.
+ if (portrait) {
+ NovaGameDetailFooter(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets.safeContent)
+ .padding(horizontal = NovaGameDetailInset, vertical = 10.dp),
+ )
}
}
@@ -326,6 +361,7 @@ private fun NovaGameDetailStatusLine(
*/
@Composable
private fun NovaGameDetailActions(
+ stacked: Boolean,
uiState: NovaGameDetailUiState,
optimizationState: NovaGameDetailOptimizationState,
playLabel: String,
@@ -341,11 +377,27 @@ private fun NovaGameDetailActions(
onEndSession: () -> Unit,
modifier: Modifier = Modifier,
) {
- Row(
- horizontalArrangement = Arrangement.spacedBy(10.dp),
- verticalAlignment = Alignment.CenterVertically,
- modifier = modifier,
- ) {
+ val lane: @Composable (@Composable () -> Unit) -> Unit = { content ->
+ if (stacked) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ modifier = modifier.fillMaxWidth(),
+ content = { content() },
+ )
+ } else {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier,
+ content = { content() },
+ )
+ }
+ }
+
+ // Stacked buttons sized to their own labels look ragged; in a column they share a width.
+ val itemWidth: Modifier = if (stacked) Modifier.fillMaxWidth() else Modifier
+
+ lane {
// Precedence: someone else's session, then yours, then the ordinary launch.
// Launching over a session another device owns would take their display.
NovaGameDetailAction(
@@ -358,7 +410,7 @@ private fun NovaGameDetailActions(
enabled = uiState.playEnabled || activeSession != null,
primary = activeSession?.watchOnly != true,
glyph = stringResource(R.string.nova_controller_hint_a),
- modifier = Modifier
+ modifier = itemWidth
.focusRequester(playFocusRequester)
.testTag("nova-game-detail-primary"),
)
@@ -368,6 +420,7 @@ private fun NovaGameDetailActions(
text = stringResource(R.string.nova_game_detail_end_session),
onClick = onEndSession,
mark = "◼",
+ modifier = itemWidth,
)
}
@@ -377,12 +430,14 @@ private fun NovaGameDetailActions(
text = stringResource(R.string.nova_library_retry_high_fps),
onClick = onRetryHighFps,
mark = "\u25B2",
+ modifier = itemWidth,
)
}
NovaGameDetailAction(
text = stringResource(R.string.nova_library_reset_game_profile),
onClick = onResetProfile,
mark = "\u21BA",
+ modifier = itemWidth,
)
} else {
if (showLaunchModeAction) {
@@ -390,22 +445,38 @@ private fun NovaGameDetailActions(
text = stringResource(R.string.nova_library_launch_mode_title),
onClick = { onDestination(NovaGameDetailDestination.LAUNCH_MODE) },
mark = "\u229E",
+ modifier = itemWidth,
)
}
NovaGameDetailAction(
text = stringResource(R.string.nova_game_detail_tune),
onClick = { onDestination(NovaGameDetailDestination.TUNE) },
mark = "\u2699",
+ modifier = itemWidth,
)
NovaGameDetailAction(
text = stringResource(R.string.nova_artwork_studio_title),
onClick = { onDestination(NovaGameDetailDestination.ARTWORK) },
mark = "\u25C8",
+ modifier = itemWidth,
)
}
}
}
+/** Dissolves the hero band into the window colour instead of cutting against it. */
+private fun Modifier.novaFadeToGround(ground: Color): Modifier = drawWithContent {
+ drawContent()
+ drawRect(
+ brush = Brush.verticalGradient(
+ colorStops = arrayOf(
+ 0.62f to Color.Transparent,
+ 1.0f to ground,
+ ),
+ ),
+ )
+}
+
/**
* One action in the lane. The primary carries the button it is bound to and an accent
* gradient; the rest are quiet, hairline-bordered and marked. Focus is a ring and a
From 557f043744cc7f9f2f93a85aba2bc9b01c70c35f Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Wed, 5 Aug 2026 20:51:47 -0400
Subject: [PATCH 06/24] fix(nova): stop the detail destinations squishing their
content
A phone in landscape is barely 430dp tall and the Retroid is 468dp. A header
sized for a tall viewport ate a third of that before any content, so Tune showed
one and a half cards and clipped Safer Fallback. Below 500dp the eyebrow and
headline say the same thing twice over, so they collapse to one line and the room
goes to the content they introduce.
The insight card had two separate faults meeting in one row. It titles itself from
the profile label and then opened its provenance chip with the same word, the same
duplication the destination headers had. And that provenance is six facts joined by
separators, a metadata line rather than a tag, so a chip could only ever ellipsise
it. The line moves under the title where it can wrap, and the reasoning gets the
room the scroll already had.
Verified on the Retroid against a live host: Tune now shows Steam Launch, Holding
and Safer Fallback together, the provenance reads to its end, and the explanation
finishes its sentence.
---
.../papi/nova/ui/NovaGameDetailActivity.kt | 2 +-
.../com/papi/nova/ui/NovaGameDetailContent.kt | 35 +++++++-----
.../nova/ui/NovaGameDetailDestinations.kt | 54 ++++++++++++-------
3 files changed, 58 insertions(+), 33 deletions(-)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
index 2a3260ba..5c2c5df0 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
@@ -1216,7 +1216,7 @@ class NovaGameDetailActivity : NovaActivity() {
""
}
val sourceText = listOf(
- stateLabel.takeIf { it.isNotBlank() },
+ stateLabel.takeIf { it.isNotBlank() && stateLabel != titleLabel },
profileState?.optString("preference_label", "")?.takeIf { it.isNotBlank() },
lastResultText.takeIf { it.isNotBlank() },
sourceLabel.takeIf { it.isNotBlank() && sourceLabel != titleLabel },
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
index 81323661..04f27d13 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
@@ -373,7 +373,9 @@ internal fun NovaGameDetailContent(
NovaGameDetailDestination.TUNE -> NovaGameDetailPanel(
eyebrow = stringResource(R.string.nova_game_detail_tune),
- headline = profilePreferenceLabel,
+ headline = stringResource(
+ AutoQualityProfilePreferences.shortLabelRes(uiState.profilePreference),
+ ),
readout = listOf(
optimizationState.profileSummary?.selectedLine,
optimizationState.profileSummary?.freshnessLine,
@@ -1739,7 +1741,7 @@ private fun SteamLaunchModeCard(
color = if (warning) colors.warning else colors.textSecondary,
fontSize = 9.sp,
lineHeight = 12.sp,
- maxLines = 2,
+ maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
@@ -1796,19 +1798,26 @@ private fun InsightCard(card: NovaGameDetailInsightCard) {
contentPadding = PaddingValues(12.dp)
) {
Column {
- Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = card.label,
+ color = if (card.isWarning) colors.warning else colors.accent,
+ fontSize = if (card.isWarning) 13.sp else 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (card.source.isNotBlank()) {
+ // Six facts joined by separators is a metadata line, not a tag; in a chip
+ // it could only ellipsise, so it wraps under the title instead.
Text(
- text = card.label,
- color = if (card.isWarning) colors.warning else colors.accent,
- fontSize = if (card.isWarning) 13.sp else 14.sp,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
+ text = card.source,
+ modifier = Modifier.padding(top = 2.dp),
+ color = colors.textMuted,
+ fontSize = 10.sp,
+ lineHeight = 13.sp,
+ maxLines = 2,
overflow = TextOverflow.Ellipsis
)
- if (card.source.isNotBlank()) {
- Spacer(modifier = Modifier.width(8.dp))
- NovaBadge(text = card.source, color = colors.textMuted)
- }
}
Text(
text = card.settings,
@@ -1827,7 +1836,7 @@ private fun InsightCard(card: NovaGameDetailInsightCard) {
color = colors.textMuted,
fontSize = 10.sp,
lineHeight = 13.sp,
- maxLines = 3,
+ maxLines = 5,
overflow = TextOverflow.Ellipsis
)
}
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
index dfab906d..766749a9 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
@@ -5,6 +5,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -61,19 +62,24 @@ internal fun NovaGameDetailPanel(
val colors = LocalNovaComposeColors.current
val surfaces = LocalNovaLibrarySurfaces.current
- Box(modifier = Modifier.fillMaxSize().background(colors.window.copy(alpha = 0.58f))) {
+ BoxWithConstraints(modifier = Modifier.fillMaxSize().background(colors.window.copy(alpha = 0.58f))) {
+ // The panel exists so the game stays visible beside what you are changing. In
+ // portrait there is nothing to sit beside, so it takes the window instead of
+ // squeezing a phone-width column inside a phone.
+ val widthFraction = if (maxHeight > maxWidth) 1f else NOVA_DETAIL_PANEL_WIDTH_FRACTION
+ val shortViewport = maxHeight < NOVA_DETAIL_SHORT_VIEWPORT
Column(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
- .fillMaxWidth(NOVA_DETAIL_PANEL_WIDTH_FRACTION)
+ .fillMaxWidth(widthFraction)
.background(colors.window)
.background(surfaces.panel)
.windowInsetsPadding(WindowInsets.safeContent)
- .padding(horizontal = NovaGameDetailInset, vertical = 20.dp)
+ .padding(horizontal = NovaGameDetailInset, vertical = if (shortViewport) 10.dp else 20.dp)
.testTag("nova-game-detail-panel"),
) {
- NovaGameDetailDestinationHeader(eyebrow, headline, readout)
+ NovaGameDetailDestinationHeader(eyebrow, headline, readout, compact = shortViewport)
Column(
modifier = Modifier
.weight(1f)
@@ -108,7 +114,7 @@ internal fun NovaGameDetailFullScreen(
.padding(horizontal = NovaGameDetailInset, vertical = 20.dp)
.testTag("nova-game-detail-fullscreen"),
) {
- NovaGameDetailDestinationHeader(eyebrow, headline, readout = "")
+ NovaGameDetailDestinationHeader(eyebrow, headline, readout = "", compact = false)
Column(
modifier = Modifier
.weight(1f)
@@ -140,26 +146,33 @@ private fun NovaGameDetailDestinationHints() {
}
@Composable
-private fun NovaGameDetailDestinationHeader(eyebrow: String, headline: String, readout: String) {
+private fun NovaGameDetailDestinationHeader(
+ eyebrow: String,
+ headline: String,
+ readout: String,
+ compact: Boolean = false,
+) {
val colors = LocalNovaComposeColors.current
- Column(modifier = Modifier.padding(bottom = 14.dp)) {
- Text(
- text = eyebrow,
- color = colors.textMuted,
- fontSize = 10.sp,
- fontWeight = FontWeight.Bold,
- letterSpacing = 0.22.em,
- )
+ Column(modifier = Modifier.padding(bottom = if (compact) 6.dp else 14.dp)) {
+ if (!compact) {
+ Text(
+ text = eyebrow,
+ color = colors.textMuted,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 0.22.em,
+ )
+ }
Text(
- text = headline,
+ text = if (compact) "$eyebrow · $headline" else headline,
color = colors.textPrimary,
- fontSize = 22.sp,
+ fontSize = if (compact) 15.sp else 22.sp,
fontWeight = FontWeight.Bold,
- maxLines = 2,
+ maxLines = if (compact) 1 else 2,
overflow = TextOverflow.Ellipsis,
- modifier = Modifier.padding(top = 3.dp),
+ modifier = Modifier.padding(top = if (compact) 0.dp else 3.dp),
)
- if (readout.isNotBlank()) {
+ if (readout.isNotBlank() && !compact) {
Text(
text = readout,
color = colors.textSecondary,
@@ -343,3 +356,6 @@ private fun NovaSteamChoiceRow(
* truncated at 53%, and still narrow enough to keep the game present beside it.
*/
private const val NOVA_DETAIL_PANEL_WIDTH_FRACTION = 0.60f
+
+/** Below this a phone in landscape has no height to spare for chrome. */
+private val NOVA_DETAIL_SHORT_VIEWPORT = 500.dp
From 868dee4c68d96268e7efed3880abd85c509ba901 Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Wed, 5 Aug 2026 21:07:29 -0400
Subject: [PATCH 07/24] feat(nova): rework the detail destinations to use the
space they have
The drill-ins sat on an opaque ground, which read as a separate screen rather than
a layer over the game. The side panel is now translucent and leans on the dimmed
game beside it for separation. The full-screen studio keeps a solid ground: with
no outside to contrast against, translucency only printed the Overview through the
artwork controls.
Back was the only way out. The dimmed area beside the panel now dismisses on tap,
and every destination carries a close control for the cases where there is no
outside to tap, which is portrait and the studio.
Tune wasted its width. A 500dp landscape body ran one narrow column and pushed the
actions below the fold. Splitting it by category put State against Insight, which
looked balanced and was not: one short card against two of prose, so the left half
sat empty while the right half wrapped every line. The row now carries the two
short things, State and Actions, and the prose spans the full width where it
reads. The studio opens expanded when it is the whole window, since starting
collapsed there costs a tap and leaves the window empty below one row.
Verified on the Retroid against a live host, in landscape at 468dp: State and
Actions share the row, Reset Game Profile is above the fold, the insight cards
read to their end, tapping beside the panel returns to the Overview, and Close
returns from the studio.
---
.../papi/nova/ui/NovaGameDetailComposeTest.kt | 1 +
.../com/papi/nova/ui/NovaArtworkStudio.kt | 4 +-
.../papi/nova/ui/NovaGameDetailActivity.kt | 1 +
.../com/papi/nova/ui/NovaGameDetailContent.kt | 94 +++++----
.../nova/ui/NovaGameDetailDestinations.kt | 179 +++++++++++++++---
app/src/main/res/values/strings.xml | 2 +
.../nova/ui/NovaComposeSourceGuardTest.kt | 10 +-
7 files changed, 225 insertions(+), 66 deletions(-)
diff --git a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt
index 70e0801c..bdf939ae 100644
--- a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt
+++ b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt
@@ -130,6 +130,7 @@ class NovaGameDetailComposeTest {
activeSession = null,
onResumeSession = {},
onEndSession = {},
+ onDismissDestination = {},
)
}
}
diff --git a/app/src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt b/app/src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt
index ac5005e4..7be75ca9 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt
@@ -288,10 +288,12 @@ fun NovaArtworkStudio(
choicePreviewLoader: (ImageView, PolarisArtworkChoice) -> Unit,
currentArtworkPresentationKey: (String) -> String,
currentArtworkLoader: (ImageView, String) -> Unit,
+ /** True when the studio is the destination rather than a row inside one. */
+ initiallyExpanded: Boolean = false,
) {
val colors = LocalNovaComposeColors.current
val surfaces = LocalNovaLibrarySurfaces.current
- var expanded by remember(initialQuery) { mutableStateOf(false) }
+ var expanded by remember(initialQuery) { mutableStateOf(initiallyExpanded) }
var query by remember(initialQuery) { mutableStateOf(initialQuery) }
val title = stringResource(R.string.nova_artwork_studio_title)
val summary = stringResource(R.string.nova_artwork_studio_summary)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
index 5c2c5df0..f4d11343 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
@@ -516,6 +516,7 @@ class NovaGameDetailActivity : NovaActivity() {
apiClient = apiClient,
sourceLabel = currentGame.sourceLabel,
onDestination = { next -> destination = next },
+ onDismissDestination = { dismissActiveDetailDestination() },
activeSession = activeSession,
onResumeSession = { finishWithSessionRequest(RESULT_SESSION_RESUME) },
onEndSession = { finishWithSessionRequest(RESULT_SESSION_END) },
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
index 04f27d13..0b285762 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
@@ -292,6 +292,7 @@ internal fun NovaGameDetailContent(
activeSession: NovaLibraryActiveSessionUiState?,
onResumeSession: () -> Unit,
onEndSession: () -> Unit,
+ onDismissDestination: () -> Unit,
) {
val verticalScroll = rememberScrollState()
val playFocusRequester = remember { FocusRequester() }
@@ -333,6 +334,7 @@ internal fun NovaGameDetailContent(
optimizationState.profileSummary?.selectedLine.orEmpty()
},
scrollState = verticalScroll,
+ onDismiss = onDismissDestination,
) {
val decision = steamDecision
if (decision != null) {
@@ -381,47 +383,61 @@ internal fun NovaGameDetailContent(
optimizationState.profileSummary?.freshnessLine,
).filter { !it.isNullOrBlank() }.joinToString(" · "),
scrollState = verticalScroll,
+ onDismiss = onDismissDestination,
) {
- NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_state))
- profileOptionsState?.let {
- NovaProfilePreferenceSheet(
- state = it,
- onSelected = onProfilePreferenceSelected,
- onDismiss = onDismissProfileOptions
- )
- }
- SteamLaunchModeCard(
- visible = uiState.showSteamLaunchMode,
- label = steamLaunchLabel,
- modeLabel = steamLaunchModeLabel,
- caption = steamLaunchCaption,
- warning = uiState.steamLaunchWarning,
- onClick = onSteamLaunchMode
+ NovaGameDetailColumns(
+ left = {
+ NovaGameDetailGroupLabel(
+ stringResource(R.string.nova_game_detail_group_state),
+ )
+ profileOptionsState?.let {
+ NovaProfilePreferenceSheet(
+ state = it,
+ onSelected = onProfilePreferenceSelected,
+ onDismiss = onDismissProfileOptions
+ )
+ }
+ SteamLaunchModeCard(
+ visible = uiState.showSteamLaunchMode,
+ label = steamLaunchLabel,
+ modeLabel = steamLaunchModeLabel,
+ caption = steamLaunchCaption,
+ warning = uiState.steamLaunchWarning,
+ onClick = onSteamLaunchMode
+ )
+ steamLaunchOptionsState?.let { state ->
+ NovaSteamLaunchModeSheet(
+ state = state,
+ onSelected = onSteamLaunchModeSelected,
+ onDismiss = onDismissSteamLaunchModeOptions
+ )
+ }
+ if (mangoHudEnabled) {
+ MangoHudPassiveStatus(
+ label = mangoHudStatusLabel,
+ caption = mangoHudStatusCaption,
+ warning = mangoHudWarning
+ )
+ }
+ },
+ right = {
+ NovaGameDetailGroupLabel(
+ stringResource(R.string.nova_game_detail_group_actions),
+ )
+ LaunchProfileSummaryActions(
+ summary = optimizationState.profileSummary,
+ resetProfileLabel = resetProfileLabel,
+ resetProfileWorking = resetProfileWorking,
+ onRetryHighFps = onRetryHighFps,
+ onResetProfile = onResetProfile,
+ )
+ },
)
- steamLaunchOptionsState?.let { state ->
- NovaSteamLaunchModeSheet(
- state = state,
- onSelected = onSteamLaunchModeSelected,
- onDismiss = onDismissSteamLaunchModeOptions
- )
- }
- if (mangoHudEnabled) {
- MangoHudPassiveStatus(
- label = mangoHudStatusLabel,
- caption = mangoHudStatusCaption,
- warning = mangoHudWarning
- )
- }
+ // Prose, not cards: half a body is not enough to read these without
+ // wrapping every line, so they keep the full width.
+ NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_insight))
optimizationState.ai?.let { InsightCard(card = it) }
optimizationState.stability?.let { InsightCard(card = it) }
- NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_actions))
- LaunchProfileSummaryActions(
- summary = optimizationState.profileSummary,
- resetProfileLabel = resetProfileLabel,
- resetProfileWorking = resetProfileWorking,
- onRetryHighFps = onRetryHighFps,
- onResetProfile = onResetProfile,
- )
}
// The studio opens with a Row of weighted Columns, so it needs the window
@@ -430,8 +446,10 @@ internal fun NovaGameDetailContent(
eyebrow = stringResource(R.string.nova_artwork_studio_title),
headline = uiState.game.name,
scrollState = verticalScroll,
+ onDismiss = onDismissDestination,
) {
NovaArtworkStudio(
+ initiallyExpanded = true,
state = artworkState,
initialQuery = uiState.game.name,
onRefresh = onRefreshArtwork,
@@ -1792,7 +1810,7 @@ private fun InsightCard(card: NovaGameDetailInsightCard) {
NovaDetailPanel(
modifier = Modifier
.fillMaxWidth()
- .padding(start = 14.dp, end = 14.dp, top = 10.dp),
+ .padding(top = 10.dp),
accent = !card.isWarning,
warning = card.isWarning,
contentPadding = PaddingValues(12.dp)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
index 766749a9..ed20503d 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
@@ -2,6 +2,8 @@ package com.papi.nova.ui
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -23,8 +25,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@@ -57,41 +63,105 @@ internal fun NovaGameDetailPanel(
headline: String,
readout: String,
scrollState: ScrollState,
+ onDismiss: () -> Unit,
content: @Composable () -> Unit,
) {
val colors = LocalNovaComposeColors.current
val surfaces = LocalNovaLibrarySurfaces.current
- BoxWithConstraints(modifier = Modifier.fillMaxSize().background(colors.window.copy(alpha = 0.58f))) {
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(colors.window.copy(alpha = NOVA_DETAIL_SCRIM_ALPHA))
+ // The dimmed area beside the panel is the game you came from, so tapping it
+ // is the same gesture as pressing back.
+ .novaDismissOnTap(onDismiss)
+ .testTag("nova-game-detail-scrim"),
+ ) {
// The panel exists so the game stays visible beside what you are changing. In
// portrait there is nothing to sit beside, so it takes the window instead of
// squeezing a phone-width column inside a phone.
val widthFraction = if (maxHeight > maxWidth) 1f else NOVA_DETAIL_PANEL_WIDTH_FRACTION
val shortViewport = maxHeight < NOVA_DETAIL_SHORT_VIEWPORT
+ val bodyWidth = maxWidth * widthFraction - NovaGameDetailInset * 2
Column(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.fillMaxWidth(widthFraction)
- .background(colors.window)
+ // Translucent, so the game reads underneath and the panel is a layer over
+ // it rather than another screen. Separation comes from the outside scrim.
+ .background(colors.window.copy(alpha = NOVA_DETAIL_PANEL_ALPHA))
.background(surfaces.panel)
+ // Taps inside the panel are not taps outside it.
+ .novaDismissOnTap {}
.windowInsetsPadding(WindowInsets.safeContent)
.padding(horizontal = NovaGameDetailInset, vertical = if (shortViewport) 10.dp else 20.dp)
.testTag("nova-game-detail-panel"),
) {
- NovaGameDetailDestinationHeader(eyebrow, headline, readout, compact = shortViewport)
- Column(
- modifier = Modifier
- .weight(1f)
- .fillMaxWidth()
- .verticalScroll(scrollState),
- content = { content() },
+ NovaGameDetailDestinationHeader(
+ eyebrow = eyebrow,
+ headline = headline,
+ readout = readout,
+ compact = shortViewport,
+ onDismiss = onDismiss,
)
+ CompositionLocalProvider(
+ LocalNovaDetailWideBody provides (bodyWidth >= NOVA_DETAIL_TWO_COLUMN_MIN),
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .verticalScroll(scrollState),
+ content = { content() },
+ )
+ }
NovaGameDetailDestinationHints()
}
}
}
+/** A tap target that swallows the gesture, with no ripple to imply a button. */
+private fun Modifier.novaDismissOnTap(onDismiss: () -> Unit): Modifier = composed {
+ clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = onDismiss,
+ )
+}
+
+/**
+ * True when the destination body has room for two readable columns. Provided by the
+ * panel because only the panel knows how much of the window it took.
+ */
+internal val LocalNovaDetailWideBody = staticCompositionLocalOf { false }
+
+/**
+ * Two columns when there is width for them, stacked when there is not. Wide, this stops
+ * a 500dp body running one narrow column with everything else below the fold.
+ */
+@Composable
+internal fun NovaGameDetailColumns(
+ left: @Composable () -> Unit,
+ right: @Composable () -> Unit,
+) {
+ if (LocalNovaDetailWideBody.current) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(modifier = Modifier.weight(1f), content = { left() })
+ Column(modifier = Modifier.weight(1f), content = { right() })
+ }
+ } else {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ left()
+ right()
+ }
+ }
+}
+
/**
* A drill-in that needs the window. Used by Artwork, whose studio lays itself out as a
* Row of weighted Columns and cannot fold into a panel.
@@ -101,28 +171,43 @@ internal fun NovaGameDetailFullScreen(
eyebrow: String,
headline: String,
scrollState: ScrollState,
+ onDismiss: () -> Unit,
content: @Composable () -> Unit,
) {
val colors = LocalNovaComposeColors.current
val surfaces = LocalNovaLibrarySurfaces.current
- Column(
- modifier = Modifier
- .fillMaxSize()
- .background(colors.window)
- .background(surfaces.panel)
- .windowInsetsPadding(WindowInsets.safeContent)
- .padding(horizontal = NovaGameDetailInset, vertical = 20.dp)
- .testTag("nova-game-detail-fullscreen"),
- ) {
- NovaGameDetailDestinationHeader(eyebrow, headline, readout = "", compact = false)
+ BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
+ val shortViewport = maxHeight < NOVA_DETAIL_SHORT_VIEWPORT
Column(
modifier = Modifier
- .weight(1f)
- .fillMaxWidth()
- .verticalScroll(scrollState),
- content = { content() },
- )
- NovaGameDetailDestinationHints()
+ .fillMaxSize()
+ // Solid: there is no outside here, so translucency would only print the
+ // Overview through the studio rather than reveal anything new.
+ .background(colors.window)
+ .background(surfaces.panel)
+ .windowInsetsPadding(WindowInsets.safeContent)
+ .padding(
+ horizontal = NovaGameDetailInset,
+ vertical = if (shortViewport) 10.dp else 20.dp,
+ )
+ .testTag("nova-game-detail-fullscreen"),
+ ) {
+ NovaGameDetailDestinationHeader(
+ eyebrow = eyebrow,
+ headline = headline,
+ readout = "",
+ compact = shortViewport,
+ onDismiss = onDismiss,
+ )
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .verticalScroll(scrollState),
+ content = { content() },
+ )
+ NovaGameDetailDestinationHints()
+ }
}
}
@@ -151,9 +236,14 @@ private fun NovaGameDetailDestinationHeader(
headline: String,
readout: String,
compact: Boolean = false,
+ onDismiss: () -> Unit = {},
) {
val colors = LocalNovaComposeColors.current
- Column(modifier = Modifier.padding(bottom = if (compact) 6.dp else 14.dp)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().padding(bottom = if (compact) 6.dp else 14.dp),
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
if (!compact) {
Text(
text = eyebrow,
@@ -184,6 +274,34 @@ private fun NovaGameDetailDestinationHeader(
)
}
}
+ // Portrait and the studio have no outside to tap, so the way out is always here.
+ NovaGameDetailCloseControl(onDismiss)
+ }
+}
+
+/** The touch equivalent of back, for the destinations that fill the window. */
+@Composable
+private fun NovaGameDetailCloseControl(onDismiss: () -> Unit) {
+ val colors = LocalNovaComposeColors.current
+ val surfaces = LocalNovaLibrarySurfaces.current
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .padding(start = 12.dp)
+ .clip(RoundedCornerShape(NovaGameDetailCornerRadius))
+ .background(surfaces.control)
+ .border(1.dp, colors.divider.copy(alpha = 0.6f), RoundedCornerShape(NovaGameDetailCornerRadius))
+ .novaDismissOnTap(onDismiss)
+ .padding(horizontal = 12.dp, vertical = 7.dp)
+ .testTag("nova-game-detail-close"),
+ ) {
+ Text(
+ text = stringResource(R.string.nova_game_detail_close),
+ color = colors.textSecondary,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
}
/**
@@ -357,5 +475,14 @@ private fun NovaSteamChoiceRow(
*/
private const val NOVA_DETAIL_PANEL_WIDTH_FRACTION = 0.60f
+/** Enough of the game stays visible for the panel to read as a layer over it. */
+private const val NOVA_DETAIL_SCRIM_ALPHA = 0.72f
+
+/** Translucent enough to show artwork, opaque enough to keep body text legible. */
+private const val NOVA_DETAIL_PANEL_ALPHA = 0.80f
+
+/** Two columns need this much body width before either becomes too narrow to read. */
+private val NOVA_DETAIL_TWO_COLUMN_MIN = 440.dp
+
/** Below this a phone in landscape has no height to spare for chrome. */
private val NOVA_DETAIL_SHORT_VIEWPORT = 500.dp
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f59c49d2..0e98e991 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -269,6 +269,8 @@
Where it runs
State
Actions
+ Insight
+ Close
✦ POLARIS
A
B
diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
index 85ace9e7..d5fd6df2 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
@@ -1227,7 +1227,15 @@ class NovaComposeSourceGuardTest {
)
val panel = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt")
- assertTrue("artwork preferences should start collapsed", panel.contains("var expanded by remember(initialQuery) { mutableStateOf(false) }"))
+ assertTrue(
+ "artwork preferences should start collapsed wherever the studio is one row among many",
+ panel.contains("initiallyExpanded: Boolean = false") &&
+ panel.contains("var expanded by remember(initialQuery) { mutableStateOf(initiallyExpanded) }")
+ )
+ assertTrue(
+ "the destination that is nothing but the studio should open it, not cost a tap and leave the window empty",
+ content.contains("NovaArtworkStudio(\n initiallyExpanded = true,")
+ )
assertTrue("artwork header should toggle expansion", panel.contains("clickable { expanded = !expanded }") && panel.contains("if (expanded)"))
assertTrue("Studio should show persisted identity and composition beside the live draft", panel.contains("R.string.nova_artwork_current_match") && panel.contains("R.string.nova_artwork_current_composition") && panel.contains("R.string.nova_artwork_live_preview"))
assertTrue("Studio should render Poster, Hero, Logo, and Icon composition layers", NovaArtworkKinds.ALL.all { kind -> panel.contains("kind = NovaArtworkKinds.${kind.uppercase()}") })
From 022f506e1ae05259526109901e6a49ec19bcee49 Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Wed, 5 Aug 2026 21:27:43 -0400
Subject: [PATCH 08/24] fix(nova): keep the destination scrim neutral across
themes
Dimming with the window colour is only dimming while the window is dark. Portable
Chrome is a light theme, so the same expression painted a white veil over the
artwork: the hero washed to flat grey and the identity line beside the panel went
nearly illegible, because that text is drawn in on-media colours that assume a
darkened image underneath. A scrim is a shadow rather than a surface, so it stays
dark whatever the theme is.
Checked in landscape on the Retroid against a live host across all six themes.
Polaris, OLED, Miami, High Contrast and Material You were already correct and are
unchanged by this; Portable Chrome now dims rather than bleaches.
---
.../com/papi/nova/ui/NovaGameDetailDestinations.kt | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
index ed20503d..d736e105 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
@@ -30,6 +30,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
@@ -72,7 +73,7 @@ internal fun NovaGameDetailPanel(
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
- .background(colors.window.copy(alpha = NOVA_DETAIL_SCRIM_ALPHA))
+ .background(NovaGameDetailScrim.copy(alpha = NOVA_DETAIL_SCRIM_ALPHA))
// The dimmed area beside the panel is the game you came from, so tapping it
// is the same gesture as pressing back.
.novaDismissOnTap(onDismiss)
@@ -475,8 +476,14 @@ private fun NovaSteamChoiceRow(
*/
private const val NOVA_DETAIL_PANEL_WIDTH_FRACTION = 0.60f
+/**
+ * A scrim is a shadow, not a surface, so it does not follow the theme. Painting it in
+ * the window colour turned into a white veil under Portable Chrome.
+ */
+private val NovaGameDetailScrim = Color.Black
+
/** Enough of the game stays visible for the panel to read as a layer over it. */
-private const val NOVA_DETAIL_SCRIM_ALPHA = 0.72f
+private const val NOVA_DETAIL_SCRIM_ALPHA = 0.62f
/** Translucent enough to show artwork, opaque enough to keep body text legible. */
private const val NOVA_DETAIL_PANEL_ALPHA = 0.80f
From baf0dd448242c0452cbf313897f86fa42af526ea Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Wed, 5 Aug 2026 21:49:39 -0400
Subject: [PATCH 09/24] feat(nova): let launch mode answer where it runs, and
give tune the profile
On device the destination said its own name three times: the header, a card titled
Launch Mode inside it, and a badge repeating what the mode pills already say. It
then spent the rest of the panel on AI Preference, the Launch Profile summary and
Reset Game Profile, which are Tune's and which Tune now holds under State and
Actions. Keeping them here was the clutter this window was built to remove, moved
one screen over. What is left is the question the concept named it for: which of
the two modes it runs in, why one is preferred, and the way to fuller settings.
The intro stops truncating mid-sentence now that it has the room.
Removing that duplicate took the only entry to the profile preference picker with
it, leaving the picker with no caller at all. The concept leads Tune's State with
the profile itself, above MangoHUD and Steam launch mode, and that is where it
goes. Pickers also stop rendering inside the State column, where half a body
truncated the title and pushed the options past the fold; they span the body,
above the groups.
Choosing a mode now returns to the Overview, as the concept specifies.
Verified on the Retroid against a live host with a virtual display backend
available, so this destination could be reached at all for the first time.
Known, pre-existing and not addressed here: selecting a launch mode persists but
does not refresh the in-memory state, so the Selected pill still shows the previous
mode until the window is reopened.
---
.../papi/nova/ui/NovaGameDetailActivity.kt | 8 +-
.../com/papi/nova/ui/NovaGameDetailContent.kt | 261 +++---------------
.../nova/ui/NovaGameDetailDestinations.kt | 2 +-
app/src/main/res/values/strings.xml | 1 +
.../nova/ui/NovaComposeSourceGuardTest.kt | 27 +-
.../papi/nova/ui/NovaLaunchSourceGuardTest.kt | 2 +-
6 files changed, 66 insertions(+), 235 deletions(-)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
index f4d11343..7b631a6e 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
@@ -539,7 +539,13 @@ class NovaGameDetailActivity : NovaActivity() {
profileOptionsState = null
}
},
- onLaunchModeSelected = ::selectLaunchMode,
+ onLaunchModeSelected = { mode ->
+ // The concept: choosing sets the mode and returns to the Overview,
+ // whose readout reflects it. Staying put left the pill showing the
+ // previous mode until the window was opened again.
+ selectLaunchMode(mode)
+ destination = NovaGameDetailDestination.OVERVIEW
+ },
onLaunchOptionSelected = { option ->
fun launchSelected(mirrorDesktop: Boolean, forcePrivateAfterSteamClose: Boolean = false) {
val selectedLaunchOptimization = option.launchOptimization ?: optimizationState.rawOptimization
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
index 0b285762..3dd4931b 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
@@ -343,25 +343,18 @@ internal fun NovaGameDetailContent(
onChoice = onSteamChoice,
)
} else {
- LaunchControlsPanel(
+ LaunchControls(
uiState = uiState,
launchIntro = launchIntro,
launchModeTitle = launchModeTitle,
- recommendedBadge = recommendedBadge,
launchOptionsLabel = launchOptionsLabel,
- profilePreferenceLabel = profilePreferenceLabel,
profileSummary = optimizationState.profileSummary,
- resetProfileLabel = resetProfileLabel,
- resetProfileWorking = resetProfileWorking,
headlessModeLabel = headlessModeLabel,
virtualDisplayModeLabel = virtualDisplayModeLabel,
playFocusRequester = playFocusRequester,
detailsFocusRequester = detailsFocusRequester,
onLaunchOptions = onLaunchOptions,
onLaunchModeSelected = onLaunchModeSelected,
- onProfilePreference = onProfilePreference,
- onRetryHighFps = onRetryHighFps,
- onResetProfile = onResetProfile
)
launchOptionsState?.let {
NovaLaunchOptionsSheet(
@@ -385,18 +378,38 @@ internal fun NovaGameDetailContent(
scrollState = verticalScroll,
onDismiss = onDismissDestination,
) {
+ // A picker is what you are doing with the destination while it is
+ // open, not a row inside one of its groups, so it spans the body,
+ // above the groups rather than below the fold they push it past.
+ profileOptionsState?.let {
+ NovaProfilePreferenceSheet(
+ state = it,
+ onSelected = onProfilePreferenceSelected,
+ onDismiss = onDismissProfileOptions
+ )
+ }
+ steamLaunchOptionsState?.let { state ->
+ NovaSteamLaunchModeSheet(
+ state = state,
+ onSelected = onSteamLaunchModeSelected,
+ onDismiss = onDismissSteamLaunchModeOptions
+ )
+ }
+
NovaGameDetailColumns(
left = {
NovaGameDetailGroupLabel(
stringResource(R.string.nova_game_detail_group_state),
)
- profileOptionsState?.let {
- NovaProfilePreferenceSheet(
- state = it,
- onSelected = onProfilePreferenceSelected,
- onDismiss = onDismissProfileOptions
- )
- }
+ // The concept leads State with the profile itself. It is also the
+ // only way into the preference picker, now that launch mode no
+ // longer carries a second copy of Tune's controls.
+ NovaSteamChoiceRow(
+ label = profilePreferenceLabel,
+ caption = stringResource(R.string.nova_game_detail_profile_caption),
+ enabled = true,
+ onClick = onProfilePreference,
+ )
SteamLaunchModeCard(
visible = uiState.showSteamLaunchMode,
label = steamLaunchLabel,
@@ -405,13 +418,6 @@ internal fun NovaGameDetailContent(
warning = uiState.steamLaunchWarning,
onClick = onSteamLaunchMode
)
- steamLaunchOptionsState?.let { state ->
- NovaSteamLaunchModeSheet(
- state = state,
- onSelected = onSteamLaunchModeSelected,
- onDismiss = onDismissSteamLaunchModeOptions
- )
- }
if (mangoHudEnabled) {
MangoHudPassiveStatus(
label = mangoHudStatusLabel,
@@ -1008,58 +1014,6 @@ private fun NovaGameDetailIdentity(
}
}
-@Composable
-private fun LaunchControlsPanel(
- uiState: NovaGameDetailUiState,
- launchIntro: String,
- launchModeTitle: String,
- recommendedBadge: String,
- launchOptionsLabel: String,
- profilePreferenceLabel: String,
- profileSummary: NovaLaunchProfileSummary?,
- resetProfileLabel: String,
- resetProfileWorking: Boolean,
- headlessModeLabel: String,
- virtualDisplayModeLabel: String,
- playFocusRequester: FocusRequester,
- detailsFocusRequester: FocusRequester,
- onLaunchOptions: () -> Unit,
- onLaunchModeSelected: (String) -> Unit,
- onProfilePreference: () -> Unit,
- onRetryHighFps: () -> Unit,
- onResetProfile: () -> Unit
-) {
- NovaDetailPanel(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 14.dp, end = 14.dp, top = 10.dp),
- contentDescription = "Launch controls",
- accent = true,
- contentPadding = PaddingValues(12.dp)
- ) {
- LaunchControls(
- uiState = uiState,
- launchIntro = launchIntro,
- launchModeTitle = launchModeTitle,
- recommendedBadge = recommendedBadge,
- launchOptionsLabel = launchOptionsLabel,
- profilePreferenceLabel = profilePreferenceLabel,
- profileSummary = profileSummary,
- resetProfileLabel = resetProfileLabel,
- resetProfileWorking = resetProfileWorking,
- headlessModeLabel = headlessModeLabel,
- virtualDisplayModeLabel = virtualDisplayModeLabel,
- playFocusRequester = playFocusRequester,
- detailsFocusRequester = detailsFocusRequester,
- onLaunchOptions = onLaunchOptions,
- onLaunchModeSelected = onLaunchModeSelected,
- onProfilePreference = onProfilePreference,
- onRetryHighFps = onRetryHighFps,
- onResetProfile = onResetProfile
- )
- }
-}
-
@Composable
private fun MetadataBadges(game: PolarisGame) {
val horizontalScroll = rememberScrollState()
@@ -1102,59 +1056,24 @@ private fun LaunchControls(
uiState: NovaGameDetailUiState,
launchIntro: String,
launchModeTitle: String,
- recommendedBadge: String,
launchOptionsLabel: String,
- profilePreferenceLabel: String,
profileSummary: NovaLaunchProfileSummary?,
- resetProfileLabel: String,
- resetProfileWorking: Boolean,
headlessModeLabel: String,
virtualDisplayModeLabel: String,
playFocusRequester: FocusRequester,
detailsFocusRequester: FocusRequester,
onLaunchOptions: () -> Unit,
onLaunchModeSelected: (String) -> Unit,
- onProfilePreference: () -> Unit,
- onRetryHighFps: () -> Unit,
- onResetProfile: () -> Unit
) {
val colors = LocalNovaComposeColors.current
Column {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = launchModeTitle,
- modifier = Modifier.weight(1f),
- color = colors.textPrimary,
- fontSize = 16.sp,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- if (uiState.showRecommendedModeBadge) {
- Spacer(modifier = Modifier.width(8.dp))
- NovaBadge(
- text = recommendedBadge,
- color = colors.onAccent,
- backgroundColor = colors.accent.copy(alpha = 0.86f),
- borderColor = colors.accent,
- fontWeight = FontWeight.SemiBold,
- fontSize = 11.sp,
- contentPadding = PaddingValues(horizontal = 10.dp, vertical = 5.dp)
- )
- }
- }
-
Text(
text = launchIntro,
- modifier = Modifier.padding(top = 6.dp),
color = if (uiState.virtualDisplayUnavailable) colors.warning else colors.textSecondary,
fontSize = 11.sp,
- lineHeight = 13.sp,
- maxLines = 2,
+ lineHeight = 14.sp,
+ maxLines = 4,
overflow = TextOverflow.Ellipsis
)
@@ -1226,56 +1145,20 @@ private fun LaunchControls(
}
}
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 9.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- if (uiState.showLaunchOptionsButton) {
- NovaActionButton(
- text = launchOptionsLabel,
- onClick = onLaunchOptions,
- modifier = Modifier.weight(1f),
- contentDescription = launchOptionsLabel,
- minHeight = 42.dp,
- cornerRadius = 10.dp,
- fontSize = 12.sp,
- contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)
- )
- }
+ if (uiState.showLaunchOptionsButton) {
NovaActionButton(
- text = profilePreferenceLabel,
- onClick = onProfilePreference,
- modifier = if (uiState.showLaunchOptionsButton) Modifier.weight(1f) else Modifier.fillMaxWidth(),
- contentDescription = profilePreferenceLabel,
+ text = launchOptionsLabel,
+ onClick = onLaunchOptions,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 9.dp),
+ contentDescription = launchOptionsLabel,
minHeight = 42.dp,
cornerRadius = 10.dp,
fontSize = 12.sp,
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)
)
}
-
- profileSummary?.let {
- LaunchProfileSummaryInline(
- summary = it,
- onRetryHighFps = onRetryHighFps
- )
- }
-
- NovaActionButton(
- text = resetProfileLabel,
- onClick = onResetProfile,
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 8.dp),
- enabled = !resetProfileWorking,
- contentDescription = resetProfileLabel,
- minHeight = 36.dp,
- cornerRadius = 10.dp,
- fontSize = 11.sp,
- contentPadding = PaddingValues(horizontal = 9.dp, vertical = 7.dp)
- )
}
}
@@ -1375,76 +1258,6 @@ internal fun LaunchProfilePrimaryNotice(
}
}
-@Composable
-private fun LaunchProfileSummaryInline(
- summary: NovaLaunchProfileSummary,
- onRetryHighFps: () -> Unit
-) {
- val colors = LocalNovaComposeColors.current
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 10.dp)
- .semantics { contentDescription = "Launch profile summary" }
- ) {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(bottom = 8.dp)
- .heightIn(min = 1.dp, max = 1.dp)
- .background(colors.divider.copy(alpha = 0.55f))
- )
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "Launch Profile",
- color = colors.accent,
- fontSize = 12.sp,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- ProfileSummaryText(summary.selectedLine, topPadding = 4)
- ProfileSummaryText(summary.requestedLine)
- ProfileSummaryText(summary.limitingLine)
- ProfileSummaryText(summary.reasonLine)
- }
- }
-
- if (summary.historyLines.isNotEmpty()) {
- Text(
- text = summary.historyLines.first(),
- modifier = Modifier.padding(top = 4.dp),
- color = colors.textMuted,
- fontSize = 10.sp,
- lineHeight = 13.sp,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- } else {
- ProfileSummaryText(summary.freshnessLine)
- }
-
- if (summary.showRetryHighFps) {
- NovaActionButton(
- text = summary.retryHighFpsLabel,
- onClick = onRetryHighFps,
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 8.dp),
- contentDescription = summary.retryHighFpsLabel,
- minHeight = 36.dp,
- cornerRadius = 8.dp,
- fontSize = 11.sp,
- contentPadding = PaddingValues(horizontal = 10.dp, vertical = 7.dp)
- )
- }
- }
-}
-
@Composable
private fun LaunchModeChoicePill(
label: String,
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
index d736e105..fc55eb37 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
@@ -437,7 +437,7 @@ internal fun NovaDesktopSteamLaunchDecisionRows(
}
@Composable
-private fun NovaSteamChoiceRow(
+internal fun NovaSteamChoiceRow(
label: String,
caption: String,
enabled: Boolean,
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0e98e991..3a2a9b80 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -271,6 +271,7 @@
Actions
Insight
Close
+ How Polaris picks quality for this game
✦ POLARIS
A
B
diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
index d5fd6df2..316320c7 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
@@ -1036,7 +1036,7 @@ class NovaComposeSourceGuardTest {
val detail = readNovaGameDetail()
val detailsPanel = detail.section(
"private fun GameDetailsPanel(",
- "@Composable\nprivate fun LaunchControlsPanel("
+ "@Composable\nprivate fun LaunchControls("
)
val launchFooter = detail.section(
"internal fun NovaGameDetailLaunchFooter(",
@@ -1117,7 +1117,7 @@ class NovaComposeSourceGuardTest {
val detail = readNovaGameDetail()
val launchControls = detail.section(
"private fun LaunchControls(",
- "@Composable\nprivate fun LaunchProfileSummaryInline("
+ "@Composable\ninternal fun LaunchProfilePrimaryNotice("
)
assertTrue(
@@ -1134,10 +1134,21 @@ class NovaComposeSourceGuardTest {
launchControls.indexOf("text = launchOptionsLabel") > launchControls.indexOf("LaunchModeChoicePill(")
)
assertTrue(
- "non-duplicative tuning should remain available separately from launch mode selection",
- launchControls.contains("text = profilePreferenceLabel") &&
- launchControls.indexOf("text = profilePreferenceLabel") > launchControls.indexOf("LaunchModeChoicePill(") &&
- launchControls.split("LaunchProfileSummaryInline(").size == 2
+ "launch mode should answer where it runs and leave the profile alone: preference, summary and reset are Tune's",
+ !launchControls.contains("profilePreferenceLabel") &&
+ !launchControls.contains("LaunchProfileSummaryInline(") &&
+ !launchControls.contains("resetProfileLabel")
+ )
+ assertTrue(
+ "Tune should hold the profile controls launch mode gave up, and stay the way into the preference picker",
+ detail.contains("onClick = onProfilePreference,") &&
+ detail.contains("LaunchProfileSummaryActions(") &&
+ detail.contains("R.string.nova_game_detail_group_actions")
+ )
+ assertTrue(
+ "launch mode should name itself once: the destination header already does, and the pills say which is recommended",
+ !launchControls.contains("text = launchModeTitle") &&
+ !launchControls.contains("text = recommendedBadge")
)
}
@@ -1157,7 +1168,7 @@ class NovaComposeSourceGuardTest {
"when MangoHUD is already enabled, the drawer should show only a passive status after launch controls",
sheetContent.contains("if (mangoHudEnabled) {") &&
sheetContent.contains("MangoHudPassiveStatus(") &&
- sheetContent.indexOf("MangoHudPassiveStatus(") > sheetContent.indexOf("LaunchControlsPanel(")
+ sheetContent.indexOf("MangoHudPassiveStatus(") > sheetContent.indexOf("LaunchControls(")
)
}
@@ -1166,7 +1177,7 @@ class NovaComposeSourceGuardTest {
val source = readNovaGameDetail()
val detailsPanel = source.section(
"private fun GameDetailsPanel(",
- "@Composable\nprivate fun LaunchControlsPanel("
+ "@Composable\nprivate fun LaunchControls("
)
assertTrue(
diff --git a/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt
index 31fb260c..afd3858a 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt
@@ -55,7 +55,7 @@ class NovaLaunchSourceGuardTest {
val nvHttp = readSource("src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt")
val decisionRows = detail.section(
"internal fun NovaDesktopSteamLaunchDecisionRows(",
- "private fun NovaSteamChoiceRow("
+ "internal fun NovaSteamChoiceRow("
)
assertTrue(
From f91c871439bd8bdbb90b3504b2b1a726e6e953ba Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Wed, 5 Aug 2026 22:06:34 -0400
Subject: [PATCH 10/24] fix(nova): give the drill-in destinations somewhere for
focus to land
The only requestFocus in the whole window was the Overview's play button, so
opening a destination left focus with no anchor: nothing took it on entry and the
d-pad moved nothing at all, on a handheld whose primary input is a d-pad. The body
is now a focus group that asks for focus once it has been laid out, which is the
idiom the library already uses for its own drawer.
Once focus had somewhere to land it could also leave. Pressing left walked out of
the panel and onto the Overview's Launch Mode button, dimmed behind the scrim, off
the panel and one press from acting. While a destination is open the Overview is
scenery, so it stops taking focus for as long as that is true.
Verified on the Retroid in landscape: Tune opens with the profile row focused, down
moves within a group, right crosses to the next one, and four presses of left now
stay inside the panel instead of falling through to the screen behind it.
---
.../com/papi/nova/ui/NovaGameDetailContent.kt | 8 ++++++
.../nova/ui/NovaGameDetailDestinations.kt | 25 +++++++++++++++++++
2 files changed, 33 insertions(+)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
index 3dd4931b..ffbf47b4 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
@@ -18,6 +18,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -320,6 +321,13 @@ internal fun NovaGameDetailContent(
activeSession = activeSession,
onResumeSession = onResumeSession,
onEndSession = onEndSession,
+ // While a destination is open the Overview is scenery. Without this, a d-pad
+ // press walks out of the panel onto a control dimmed behind the scrim.
+ modifier = if (destination == NovaGameDetailDestination.OVERVIEW) {
+ Modifier
+ } else {
+ Modifier.focusGroup().focusProperties { canFocus = false }
+ },
)
when (destination) {
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
index fc55eb37..2cb5be7e 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
@@ -3,6 +3,7 @@ package com.papi.nova.ui
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
@@ -26,10 +27,13 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
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.composed
import androidx.compose.ui.draw.clip
@@ -40,6 +44,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.delay
import com.papi.nova.R
import com.papi.nova.ui.compose.LocalNovaComposeColors
import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces
@@ -114,6 +119,7 @@ internal fun NovaGameDetailPanel(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
+ .novaHoldsFirstFocus()
.verticalScroll(scrollState),
content = { content() },
)
@@ -123,6 +129,21 @@ internal fun NovaGameDetailPanel(
}
}
+/**
+ * Takes focus for its first focusable child once it has been laid out. Without this a
+ * destination opens with focus left behind on the Overview, so the d-pad has nothing to
+ * move. The delay is the same one the library uses: the request only lands after layout.
+ */
+@Composable
+private fun Modifier.novaHoldsFirstFocus(): Modifier {
+ val requester = remember { FocusRequester() }
+ LaunchedEffect(Unit) {
+ delay(NOVA_DETAIL_FOCUS_SETTLE_MS)
+ runCatching { requester.requestFocus() }
+ }
+ return focusRequester(requester).focusGroup()
+}
+
/** A tap target that swallows the gesture, with no ripple to imply a button. */
private fun Modifier.novaDismissOnTap(onDismiss: () -> Unit): Modifier = composed {
clickable(
@@ -204,6 +225,7 @@ internal fun NovaGameDetailFullScreen(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
+ .novaHoldsFirstFocus()
.verticalScroll(scrollState),
content = { content() },
)
@@ -491,5 +513,8 @@ private const val NOVA_DETAIL_PANEL_ALPHA = 0.80f
/** Two columns need this much body width before either becomes too narrow to read. */
private val NOVA_DETAIL_TWO_COLUMN_MIN = 440.dp
+/** Long enough for the body to be laid out, so the focus request has a target. */
+private const val NOVA_DETAIL_FOCUS_SETTLE_MS = 75L
+
/** Below this a phone in landscape has no height to spare for chrome. */
private val NOVA_DETAIL_SHORT_VIEWPORT = 500.dp
From fbdbe1db071f58f141f1d15550967e85f7fa7ed9 Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Wed, 5 Aug 2026 22:20:30 -0400
Subject: [PATCH 11/24] feat(nova): rebuild the drawer interiors as full-bleed
rows
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The drawers read as a stack of rounded bubbles: cards floating inside a panel, each
large and mostly empty, with small text inside them. Wide and barren at the same
time. The approved concept never asked for cards. It specified 48dp rows in a 438dp
lane, label on the left and value on the right, and focus shown as an inset accent
bar that does not move the row.
So the card goes. Rows run edge to edge, separated by a hairline rather than by air,
with the value set in the tabular figures the Overview readout already uses. The
two-column split goes with it: that was mine rather than the concept's, and uneven
column heights were what left the holes. Tune is one lane again, State then Actions
then Insight, and the insight stops being another box.
The rest of the drawn tokens follow: the lane is 53% rather than 60%, the headline is
27sp rather than 22, group labels are 8sp, and the scrim is back to the 58% it was
specified at.
One trap worth recording. Replacing the card cost the rows their focus, because
Modifier.clickable alone did not register them as focus targets and the d-pad had
nothing to reach — six presses moved nothing and scrolled nothing. Every other
focusable in this app pairs clickable with an explicit focusable(), and so does this
one now.
Verified on the Retroid in landscape: rows take focus on entry, down moves between
them, and the focused row shows the bar and tint without moving.
---
.../com/papi/nova/ui/NovaGameDetailContent.kt | 159 ++++++-----------
.../nova/ui/NovaGameDetailDestinations.kt | 166 +++++++++++-------
app/src/main/res/values/strings.xml | 1 +
3 files changed, 152 insertions(+), 174 deletions(-)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
index ffbf47b4..08e05bdb 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
@@ -404,51 +404,42 @@ internal fun NovaGameDetailContent(
)
}
- NovaGameDetailColumns(
- left = {
- NovaGameDetailGroupLabel(
- stringResource(R.string.nova_game_detail_group_state),
- )
- // The concept leads State with the profile itself. It is also the
- // only way into the preference picker, now that launch mode no
- // longer carries a second copy of Tune's controls.
- NovaSteamChoiceRow(
- label = profilePreferenceLabel,
- caption = stringResource(R.string.nova_game_detail_profile_caption),
- enabled = true,
- onClick = onProfilePreference,
- )
- SteamLaunchModeCard(
- visible = uiState.showSteamLaunchMode,
- label = steamLaunchLabel,
- modeLabel = steamLaunchModeLabel,
- caption = steamLaunchCaption,
- warning = uiState.steamLaunchWarning,
- onClick = onSteamLaunchMode
- )
- if (mangoHudEnabled) {
- MangoHudPassiveStatus(
- label = mangoHudStatusLabel,
- caption = mangoHudStatusCaption,
- warning = mangoHudWarning
- )
- }
- },
- right = {
- NovaGameDetailGroupLabel(
- stringResource(R.string.nova_game_detail_group_actions),
- )
- LaunchProfileSummaryActions(
- summary = optimizationState.profileSummary,
- resetProfileLabel = resetProfileLabel,
- resetProfileWorking = resetProfileWorking,
- onRetryHighFps = onRetryHighFps,
- onResetProfile = onResetProfile,
- )
- },
+ NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_state))
+ // The concept leads State with the profile itself, and it is the only way
+ // into the preference picker now that launch mode no longer carries a
+ // second copy of Tune's controls.
+ NovaSteamChoiceRow(
+ label = stringResource(R.string.nova_game_detail_profile_label),
+ caption = stringResource(R.string.nova_game_detail_profile_caption),
+ enabled = true,
+ onClick = onProfilePreference,
+ value = stringResource(
+ AutoQualityProfilePreferences.shortLabelRes(uiState.profilePreference),
+ ),
+ )
+ SteamLaunchModeCard(
+ visible = uiState.showSteamLaunchMode,
+ label = steamLaunchLabel,
+ modeLabel = steamLaunchModeLabel,
+ caption = steamLaunchCaption,
+ warning = uiState.steamLaunchWarning,
+ onClick = onSteamLaunchMode
+ )
+ if (mangoHudEnabled) {
+ MangoHudPassiveStatus(
+ label = mangoHudStatusLabel,
+ caption = mangoHudStatusCaption,
+ warning = mangoHudWarning
+ )
+ }
+ NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_actions))
+ LaunchProfileSummaryActions(
+ summary = optimizationState.profileSummary,
+ resetProfileLabel = resetProfileLabel,
+ resetProfileWorking = resetProfileWorking,
+ onRetryHighFps = onRetryHighFps,
+ onResetProfile = onResetProfile,
)
- // Prose, not cards: half a body is not enough to read these without
- // wrapping every line, so they keep the full width.
NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_insight))
optimizationState.ai?.let { InsightCard(card = it) }
optimizationState.stability?.let { InsightCard(card = it) }
@@ -1553,40 +1544,13 @@ private fun SteamLaunchModeCard(
) {
if (!visible) return
- val colors = LocalNovaComposeColors.current
- NovaFocusableCard(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 14.dp, end = 14.dp, top = 10.dp)
- .heightIn(min = 58.dp),
+ NovaSteamChoiceRow(
+ label = label,
+ caption = caption,
+ enabled = true,
onClick = onClick,
- contentDescription = "$label. $modeLabel. $caption",
- contentPadding = PaddingValues(start = 12.dp, top = 9.dp, end = 12.dp, bottom = 9.dp)
- ) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = label,
- color = colors.textPrimary,
- fontSize = 12.sp,
- fontWeight = FontWeight.SemiBold
- )
- Text(
- text = caption,
- modifier = Modifier.padding(top = 2.dp),
- color = if (warning) colors.warning else colors.textSecondary,
- fontSize = 9.sp,
- lineHeight = 12.sp,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis
- )
- }
- NovaBadge(text = modeLabel, color = if (warning) colors.warning else colors.textSecondary)
- }
- }
+ value = modeLabel,
+ )
}
@Composable
@@ -1595,46 +1559,21 @@ private fun MangoHudPassiveStatus(
caption: String,
warning: Boolean
) {
- val colors = LocalNovaComposeColors.current
- val surfaces = LocalNovaLibrarySurfaces.current
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 14.dp, end = 14.dp, top = 8.dp)
- .semantics { contentDescription = "$label. $caption" },
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- NovaBadge(
- text = label,
- color = if (warning) colors.warning else colors.textSecondary,
- backgroundColor = surfaces.control.copy(alpha = 0.56f * LocalNovaMenuOpacityScale.current),
- borderColor = if (warning) colors.warning.copy(alpha = 0.44f) else surfaces.tileBorder,
- fontSize = 10.sp,
- contentPadding = PaddingValues(horizontal = 9.dp, vertical = 4.dp)
- )
- Text(
- text = caption,
- modifier = Modifier.weight(1f),
- color = colors.textMuted,
- fontSize = 10.sp,
- lineHeight = 13.sp,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis
- )
- }
+ // A readout, not an action: same row, no chevron to imply otherwise.
+ NovaSteamChoiceRow(
+ label = label,
+ caption = caption,
+ enabled = !warning,
+ )
}
@Composable
private fun InsightCard(card: NovaGameDetailInsightCard) {
val colors = LocalNovaComposeColors.current
- NovaDetailPanel(
+ Box(
modifier = Modifier
.fillMaxWidth()
- .padding(top = 10.dp),
- accent = !card.isWarning,
- warning = card.isWarning,
- contentPadding = PaddingValues(12.dp)
+ .padding(top = 12.dp, bottom = 2.dp),
) {
Column {
Text(
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
index 2cb5be7e..962e92b7 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
@@ -4,6 +4,7 @@ import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
+import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
@@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.width
@@ -24,21 +26,32 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -89,7 +102,6 @@ internal fun NovaGameDetailPanel(
// squeezing a phone-width column inside a phone.
val widthFraction = if (maxHeight > maxWidth) 1f else NOVA_DETAIL_PANEL_WIDTH_FRACTION
val shortViewport = maxHeight < NOVA_DETAIL_SHORT_VIEWPORT
- val bodyWidth = maxWidth * widthFraction - NovaGameDetailInset * 2
Column(
modifier = Modifier
.align(Alignment.CenterEnd)
@@ -112,18 +124,14 @@ internal fun NovaGameDetailPanel(
compact = shortViewport,
onDismiss = onDismiss,
)
- CompositionLocalProvider(
- LocalNovaDetailWideBody provides (bodyWidth >= NOVA_DETAIL_TWO_COLUMN_MIN),
- ) {
- Column(
- modifier = Modifier
- .weight(1f)
- .fillMaxWidth()
- .novaHoldsFirstFocus()
- .verticalScroll(scrollState),
- content = { content() },
- )
- }
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .novaHoldsFirstFocus()
+ .verticalScroll(scrollState),
+ content = { content() },
+ )
NovaGameDetailDestinationHints()
}
}
@@ -153,37 +161,6 @@ private fun Modifier.novaDismissOnTap(onDismiss: () -> Unit): Modifier = compose
)
}
-/**
- * True when the destination body has room for two readable columns. Provided by the
- * panel because only the panel knows how much of the window it took.
- */
-internal val LocalNovaDetailWideBody = staticCompositionLocalOf { false }
-
-/**
- * Two columns when there is width for them, stacked when there is not. Wide, this stops
- * a 500dp body running one narrow column with everything else below the fold.
- */
-@Composable
-internal fun NovaGameDetailColumns(
- left: @Composable () -> Unit,
- right: @Composable () -> Unit,
-) {
- if (LocalNovaDetailWideBody.current) {
- Row(
- horizontalArrangement = Arrangement.spacedBy(14.dp),
- modifier = Modifier.fillMaxWidth(),
- ) {
- Column(modifier = Modifier.weight(1f), content = { left() })
- Column(modifier = Modifier.weight(1f), content = { right() })
- }
- } else {
- Column(modifier = Modifier.fillMaxWidth()) {
- left()
- right()
- }
- }
-}
-
/**
* A drill-in that needs the window. Used by Artwork, whose studio lays itself out as a
* Row of weighted Columns and cannot fold into a panel.
@@ -279,7 +256,7 @@ private fun NovaGameDetailDestinationHeader(
Text(
text = if (compact) "$eyebrow · $headline" else headline,
color = colors.textPrimary,
- fontSize = if (compact) 15.sp else 22.sp,
+ fontSize = if (compact) 17.sp else 27.sp,
fontWeight = FontWeight.Bold,
maxLines = if (compact) 1 else 2,
overflow = TextOverflow.Ellipsis,
@@ -343,7 +320,7 @@ internal fun NovaGameDetailGroupLabel(text: String) {
Text(
text = text.uppercase(),
color = colors.textMuted,
- fontSize = 10.sp,
+ fontSize = 8.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.22.em,
)
@@ -458,26 +435,65 @@ internal fun NovaDesktopSteamLaunchDecisionRows(
}
}
+/**
+ * One row of a drawer. Full bleed rather than a card: the panel already supplies the
+ * inset, so a rounded box inside it only adds air. The value sits at the right in
+ * tabular figures, and focus is an inset bar and a tint, so the row never moves.
+ */
@Composable
internal fun NovaSteamChoiceRow(
label: String,
caption: String,
enabled: Boolean,
- onClick: () -> Unit,
+ onClick: (() -> Unit)? = null,
+ value: String = "",
) {
val colors = LocalNovaComposeColors.current
- NovaFocusableCard(
- onClick = onClick,
- enabled = enabled,
- contentDescription = label,
- modifier = Modifier.fillMaxWidth(),
+ var focused by remember { mutableStateOf(false) }
+ val actionable = onClick != null && enabled
+ val accentBar = colors.accent
+ val hairline = colors.divider.copy(alpha = 0.45f)
+ val barWidth = NOVA_DETAIL_ROW_FOCUS_BAR
+ val tint = colors.accent.copy(alpha = 0.16f)
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = NOVA_DETAIL_ROW_MIN_HEIGHT)
+ .onFocusChanged { focused = it.isFocused || it.hasFocus }
+ .then(
+ if (actionable) {
+ Modifier.clickable(role = Role.Button) { onClick?.invoke() }
+ } else {
+ Modifier
+ }
+ )
+ // Explicit, like every other focusable in the app: clickable alone did not
+ // register the row as a focus target and the d-pad had nothing to reach.
+ .focusable(enabled = actionable)
+ .background(if (focused && actionable) tint else Color.Transparent)
+ .drawBehind {
+ if (focused && actionable) {
+ drawRect(color = accentBar, size = Size(barWidth.toPx(), size.height))
+ }
+ drawRect(
+ color = hairline,
+ topLeft = Offset(0f, size.height - 1f),
+ size = Size(size.width, 1f),
+ )
+ }
+ .padding(start = 12.dp, end = 2.dp, top = 9.dp, bottom = 9.dp)
+ .semantics { contentDescription = if (value.isBlank()) label else "$label. $value" },
) {
- Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
+ Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
color = if (enabled) colors.textPrimary else colors.textMuted,
- fontSize = 13.sp,
+ fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
)
if (caption.isNotBlank()) {
Text(
@@ -485,18 +501,43 @@ internal fun NovaSteamChoiceRow(
color = colors.textMuted,
fontSize = 11.sp,
lineHeight = 14.sp,
- modifier = Modifier.padding(top = 3.dp),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 2.dp),
)
}
}
+ if (value.isNotBlank()) {
+ Text(
+ text = value,
+ color = if (enabled) colors.textSecondary else colors.textMuted,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Medium,
+ // a value read against other values, so the digits line up
+ style = LocalTextStyle.current.copy(fontFeatureSettings = "tnum"),
+ maxLines = 1,
+ modifier = Modifier.padding(start = 12.dp),
+ )
+ }
+ if (actionable) {
+ Text(
+ text = "\u203a",
+ color = colors.textMuted,
+ fontSize = 17.sp,
+ modifier = Modifier.padding(start = 10.dp, end = 4.dp),
+ )
+ }
}
}
-/**
- * Wide enough for the insight cards, which carry a single-line profile badge that
- * truncated at 53%, and still narrow enough to keep the game present beside it.
- */
-private const val NOVA_DETAIL_PANEL_WIDTH_FRACTION = 0.60f
+/** 438dp of an 832dp landscape shell, as drawn. */
+private const val NOVA_DETAIL_PANEL_WIDTH_FRACTION = 0.53f
+
+/** Every row is at least a full action's worth of height. */
+private val NOVA_DETAIL_ROW_MIN_HEIGHT = 48.dp
+
+/** The focused row grows a bar at its edge instead of a border that moves it. */
+private val NOVA_DETAIL_ROW_FOCUS_BAR = 3.dp
/**
* A scrim is a shadow, not a surface, so it does not follow the theme. Painting it in
@@ -505,14 +546,11 @@ private const val NOVA_DETAIL_PANEL_WIDTH_FRACTION = 0.60f
private val NovaGameDetailScrim = Color.Black
/** Enough of the game stays visible for the panel to read as a layer over it. */
-private const val NOVA_DETAIL_SCRIM_ALPHA = 0.62f
+private const val NOVA_DETAIL_SCRIM_ALPHA = 0.58f
/** Translucent enough to show artwork, opaque enough to keep body text legible. */
private const val NOVA_DETAIL_PANEL_ALPHA = 0.80f
-/** Two columns need this much body width before either becomes too narrow to read. */
-private val NOVA_DETAIL_TWO_COLUMN_MIN = 440.dp
-
/** Long enough for the body to be laid out, so the focus request has a target. */
private const val NOVA_DETAIL_FOCUS_SETTLE_MS = 75L
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3a2a9b80..f1ed2f46 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -271,6 +271,7 @@
Actions
Insight
Close
+ Optimization profile
How Polaris picks quality for this game
✦ POLARIS
A
From e89743a05c13a83cfc4118d87c358f59bfd25fbd Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Wed, 5 Aug 2026 22:26:06 -0400
Subject: [PATCH 12/24] feat(nova): make launch mode's choices rows like the
rest of the drawer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Tune became rows but launch mode still offered its two modes as rounded pills side
by side, with its settings as another pill underneath — the same bubbles, one screen
over. The concept draws these as a list: the mode, why you would pick it, and where
it stands on the right. So they are the same row as everything else now, carrying
Selected, Recommended, Available or Unavailable as their value, and the captions say
what each mode actually does rather than leaving the label to carry it alone.
The choice pill has no callers left and goes. Three guards used it as a section
boundary or pinned its 52dp height; they move to the composable that is really there
and to the shared 48dp row token, which is what enforces that height now.
The heads-up banner stays a banner, as drawn.
Verified on the Retroid in landscape: both modes and the settings row read as one
list, focus lands on the selected mode and moves between them with the accent bar.
---
.../com/papi/nova/ui/NovaGameDetailContent.kt | 127 +++++-------------
app/src/main/res/values/strings.xml | 3 +
.../nova/ui/NovaComposeSourceGuardTest.kt | 20 ++-
3 files changed, 46 insertions(+), 104 deletions(-)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
index 08e05bdb..6ac733bf 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
@@ -1106,56 +1106,45 @@ private fun LaunchControls(
}
if (uiState.showLaunchOptionsButton || uiState.showVirtualUnavailableHint) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 10.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- LaunchModeChoicePill(
- label = headlessModeLabel,
- status = when {
- uiState.playMode == "headless" -> "Selected"
- uiState.recommendedMode == "headless" && uiState.headlessAllowed -> "Recommended"
- uiState.headlessAllowed -> "Available"
- else -> "Unavailable"
- },
- recommended = uiState.recommendedMode == "headless" && uiState.headlessAllowed,
- selected = uiState.playMode == "headless",
- unavailable = !uiState.headlessAllowed,
- onClick = { onLaunchModeSelected("headless") },
- modifier = Modifier.weight(1f)
- )
- LaunchModeChoicePill(
- label = virtualDisplayModeLabel,
- status = when {
- uiState.virtualDisplayUnavailable -> "Unavailable"
- uiState.playMode == "virtual_display" -> "Selected"
- uiState.recommendedMode == "virtual_display" && uiState.virtualDisplayAllowed -> "Recommended"
- uiState.virtualDisplayAllowed -> "Available"
- else -> "Unavailable"
- },
- recommended = uiState.recommendedMode == "virtual_display" && uiState.virtualDisplayAllowed,
- selected = uiState.playMode == "virtual_display",
- unavailable = uiState.virtualDisplayUnavailable || !uiState.virtualDisplayAllowed,
- onClick = { onLaunchModeSelected("virtual_display") },
- modifier = Modifier.weight(1f)
- )
- }
+ // The two modes are a list of choices, so they read as rows like the rest of
+ // the drawer, each carrying its standing as the value.
+ NovaSteamChoiceRow(
+ label = headlessModeLabel,
+ caption = stringResource(R.string.nova_game_detail_headless_caption),
+ enabled = uiState.headlessAllowed,
+ onClick = { onLaunchModeSelected("headless") },
+ value = when {
+ uiState.playMode == "headless" -> "Selected"
+ uiState.recommendedMode == "headless" && uiState.headlessAllowed -> "Recommended"
+ uiState.headlessAllowed -> "Available"
+ else -> "Unavailable"
+ },
+ )
+ NovaSteamChoiceRow(
+ label = virtualDisplayModeLabel,
+ caption = if (uiState.showVirtualUnavailableHint) {
+ uiState.virtualDisplayUnavailableReason
+ } else {
+ stringResource(R.string.nova_game_detail_virtual_caption)
+ },
+ enabled = uiState.virtualDisplayAllowed && !uiState.virtualDisplayUnavailable,
+ onClick = { onLaunchModeSelected("virtual_display") },
+ value = when {
+ uiState.virtualDisplayUnavailable -> "Unavailable"
+ uiState.playMode == "virtual_display" -> "Selected"
+ uiState.recommendedMode == "virtual_display" && uiState.virtualDisplayAllowed -> "Recommended"
+ uiState.virtualDisplayAllowed -> "Available"
+ else -> "Unavailable"
+ },
+ )
}
if (uiState.showLaunchOptionsButton) {
- NovaActionButton(
- text = launchOptionsLabel,
+ NovaSteamChoiceRow(
+ label = launchOptionsLabel,
+ caption = stringResource(R.string.nova_game_detail_more_settings_caption),
+ enabled = true,
onClick = onLaunchOptions,
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 9.dp),
- contentDescription = launchOptionsLabel,
- minHeight = 42.dp,
- cornerRadius = 10.dp,
- fontSize = 12.sp,
- contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)
)
}
}
@@ -1257,52 +1246,6 @@ internal fun LaunchProfilePrimaryNotice(
}
}
-@Composable
-private fun LaunchModeChoicePill(
- label: String,
- status: String,
- recommended: Boolean,
- selected: Boolean,
- unavailable: Boolean,
- onClick: () -> Unit,
- modifier: Modifier = Modifier
-) {
- val colors = LocalNovaComposeColors.current
- val statusColor = when {
- unavailable -> colors.warning
- selected || recommended -> colors.accent
- else -> colors.textMuted
- }
-
- NovaFocusableCard(
- modifier = modifier.heightIn(min = 52.dp),
- onClick = onClick,
- enabled = !unavailable,
- contentDescription = "$label. $status",
- contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)
- ) {
- Column {
- Text(
- text = label,
- color = colors.textPrimary,
- fontSize = 12.sp,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- Text(
- text = status,
- modifier = Modifier.padding(top = 3.dp),
- color = statusColor,
- fontSize = 10.sp,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- }
- }
-}
-
@Composable
private fun ProfileSummaryText(text: String, topPadding: Int = 3) {
if (text.isBlank()) return
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f1ed2f46..77be5501 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -271,6 +271,9 @@
Actions
Insight
Close
+ Streams without touching the physical desktop
+ A dedicated display on the host, at your resolution
+ Resolution, bitrate and codec for this launch
Optimization profile
How Polaris picks quality for this game
✦ POLARIS
diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
index 316320c7..3124a629 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
@@ -1042,11 +1042,6 @@ class NovaComposeSourceGuardTest {
"internal fun NovaGameDetailLaunchFooter(",
"@Composable\nprivate fun NovaDetailPanel("
)
- val launchModePill = detail.section(
- "private fun LaunchModeChoicePill(",
- "@Composable\nprivate fun ProfileSummaryText("
- )
-
assertTrue(
"Retroid landscape first paint should keep either Hero or Poster identity inside the compact launch header ceiling",
detailsPanel.contains(".heightIn(min = 136.dp)") &&
@@ -1066,7 +1061,7 @@ class NovaComposeSourceGuardTest {
assertTrue(
"pinned primary launch and mode choice controls should stay compact enough for Retroid landscape",
launchFooter.contains("minHeight = 48.dp") &&
- launchModePill.contains("modifier = modifier.heightIn(min = 52.dp)")
+ detail.contains("NOVA_DETAIL_ROW_MIN_HEIGHT = 48.dp")
)
}
@@ -1121,17 +1116,18 @@ class NovaComposeSourceGuardTest {
)
assertTrue(
- "Headless/Virtual should be directly selectable from the detail sheet before launching",
- launchControls.contains("LaunchModeChoicePill(") &&
+ "Headless/Virtual should be directly selectable from the destination, as rows carrying their standing",
+ launchControls.contains("NovaSteamChoiceRow(") &&
launchControls.contains("onClick = { onLaunchModeSelected(\"headless\") }") &&
- launchControls.contains("onClick = { onLaunchModeSelected(\"virtual_display\") }")
+ launchControls.contains("onClick = { onLaunchModeSelected(\"virtual_display\") }") &&
+ !launchControls.contains("LaunchModeChoicePill(")
)
assertTrue(
"Launch Options should remain a secondary path after inline Headless/Virtual choices",
detail.contains("private fun showLaunchOptions(") &&
detail.contains("onLaunchOptions = {") &&
- launchControls.contains("text = launchOptionsLabel") &&
- launchControls.indexOf("text = launchOptionsLabel") > launchControls.indexOf("LaunchModeChoicePill(")
+ launchControls.contains("label = launchOptionsLabel") &&
+ launchControls.indexOf("label = launchOptionsLabel") > launchControls.indexOf("label = headlessModeLabel")
)
assertTrue(
"launch mode should answer where it runs and leave the profile alone: preference, summary and reset are Tune's",
@@ -2707,7 +2703,7 @@ class NovaComposeSourceGuardTest {
fun gameDetailLaunchOptionsUseActionableModeState() {
val launchControls = readNovaGameDetail().section(
"private fun LaunchControls(",
- "@Composable\nprivate fun LaunchModeChoicePill("
+ "@Composable\ninternal fun LaunchProfilePrimaryNotice("
)
assertTrue(launchControls.contains("uiState.showLaunchOptionsButton"))
From e7f23fb0432c988614ea0a83c7d41173b20cdd9b Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Wed, 5 Aug 2026 22:40:50 -0400
Subject: [PATCH 13/24] fix(nova): make the row focus and the heads-up badge
survive a light theme
Two versions of one mistake. A fixed alpha is not a fixed strength: the accent is
light against a dark surface and dark against a light one, so the focus tint that
reads as a whisper under Polaris read as an inverted block under Portable Chrome.
Measured on device, the focused row there shifted 39 points per channel against 7 to
22 everywhere else.
Scaling the alpha by polarity fixes it, but the polarity has to be read from
something that tracks the surface. The first attempt asked colors.window and never
fired, because under Portable Chrome the panel takes its lightness from
surfaces.panel layered over the window, so the window is the wrong ground to ask. It
asks the text colour now, which always contrasts with whatever it sits on. That
brings the shift to 29 points, inside the 19 to 33 the other five sit in.
The heads-up badge had the same shape of bug from the other direction: it painted its
label in the tone colour on a translucent control surface, over a ground already
tinted with that same tone. Amber on amber, illegible, and only visible now that
launch mode can be opened at all. A badge is a surface rather than media, so it takes
an opaque tone fill with ink chosen from that tone's own luminance.
Swept all six themes on the rebuilt rows, since the earlier sweep predates them.
---
.../java/com/papi/nova/ui/NovaGameDetailContent.kt | 11 ++++++++---
.../com/papi/nova/ui/NovaGameDetailDestinations.kt | 12 +++++++++++-
2 files changed, 19 insertions(+), 4 deletions(-)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
index 6ac733bf..1f21168d 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
@@ -59,6 +59,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
@@ -1189,9 +1191,12 @@ internal fun LaunchProfilePrimaryNotice(
) {
NovaBadge(
text = badgeLabel,
- color = toneColor,
- backgroundColor = surfaces.control.copy(alpha = 0.72f * LocalNovaMenuOpacityScale.current),
- borderColor = toneColor.copy(alpha = 0.35f),
+ // A badge is a surface, not media: translucent control over a ground
+ // already tinted with this tone put amber on amber. Opaque tone, with
+ // ink picked from the tone itself, reads in every theme.
+ color = if (toneColor.luminance() > 0.5f) Color.Black else Color.White,
+ backgroundColor = toneColor,
+ borderColor = Color.Transparent,
fontWeight = FontWeight.SemiBold
)
Text(
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
index 962e92b7..56f387d9 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
@@ -49,6 +49,7 @@ import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -454,7 +455,16 @@ internal fun NovaSteamChoiceRow(
val accentBar = colors.accent
val hairline = colors.divider.copy(alpha = 0.45f)
val barWidth = NOVA_DETAIL_ROW_FOCUS_BAR
- val tint = colors.accent.copy(alpha = 0.16f)
+ // The accent is light on a dark surface and dark on a light one, so the same alpha
+ // is a whisper in one theme and an inverted block in the other. Scale it by the
+ // polarity; the bar, not the fill, is what says this row has focus.
+ //
+ // Polarity comes from the text rather than colors.window, because under Portable
+ // Chrome the panel takes its lightness from surfaces.panel layered over the window,
+ // so the window is the wrong ground to ask and the tint stayed at full strength.
+ val tint = colors.accent.copy(
+ alpha = if (colors.textPrimary.luminance() < 0.5f) 0.07f else 0.16f,
+ )
Row(
verticalAlignment = Alignment.CenterVertically,
From 01a54f03639c07245e1a033fc34d2d1974affef7 Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Wed, 5 Aug 2026 22:49:04 -0400
Subject: [PATCH 14/24] feat(nova): mark the cut where a drawer body scrolls
under its hint bar
The concept pins the header and the hint bar and dissolves the last 52dp of the body,
so what is below the fold reads as continuing rather than as clipped. Until now the
insight simply collided with the hint bar mid-word.
The fade erases content alpha rather than painting a ground over it. The panel is
translucent, so a solid band would have striped window colour across the artwork
showing through it.
---
.../nova/ui/NovaGameDetailDestinations.kt | 33 +++++++++++++++++++
1 file changed, 33 insertions(+)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
index 56f387d9..505edac2 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
@@ -39,16 +39,21 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
@@ -129,6 +134,7 @@ internal fun NovaGameDetailPanel(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
+ .novaFadeAtCut()
.novaHoldsFirstFocus()
.verticalScroll(scrollState),
content = { content() },
@@ -153,6 +159,29 @@ private fun Modifier.novaHoldsFirstFocus(): Modifier {
return focusRequester(requester).focusGroup()
}
+/**
+ * Dissolves the last band of a scrolling body, so what passes under the hint bar reads
+ * as continuing rather than as clipped. It erases content alpha instead of painting a
+ * ground: the panel is translucent, and a solid band would stripe window colour across
+ * the artwork showing through it.
+ */
+private fun Modifier.novaFadeAtCut(): Modifier = this
+ .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
+ .drawWithContent {
+ drawContent()
+ val fade = NOVA_DETAIL_BOTTOM_FADE.toPx().coerceAtMost(size.height)
+ drawRect(
+ brush = Brush.verticalGradient(
+ colors = listOf(Color.Black, Color.Transparent),
+ startY = size.height - fade,
+ endY = size.height,
+ ),
+ topLeft = Offset(0f, size.height - fade),
+ size = Size(size.width, fade),
+ blendMode = BlendMode.DstIn,
+ )
+ }
+
/** A tap target that swallows the gesture, with no ripple to imply a button. */
private fun Modifier.novaDismissOnTap(onDismiss: () -> Unit): Modifier = composed {
clickable(
@@ -203,6 +232,7 @@ internal fun NovaGameDetailFullScreen(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
+ .novaFadeAtCut()
.novaHoldsFirstFocus()
.verticalScroll(scrollState),
content = { content() },
@@ -549,6 +579,9 @@ private val NOVA_DETAIL_ROW_MIN_HEIGHT = 48.dp
/** The focused row grows a bar at its edge instead of a border that moves it. */
private val NOVA_DETAIL_ROW_FOCUS_BAR = 3.dp
+/** The body dissolves over this much before the hint bar, marking the cut. */
+private val NOVA_DETAIL_BOTTOM_FADE = 52.dp
+
/**
* A scrim is a shadow, not a surface, so it does not follow the theme. Painting it in
* the window colour turned into a white veil under Portable Chrome.
From 8387d373810ab6270b64ff7a7289542c20affff5 Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Wed, 5 Aug 2026 23:54:15 -0400
Subject: [PATCH 15/24] feat(nova): give the launch mode you pick somewhere to
live
Choosing in the Launch Mode destination did nothing. It wrote into
launchMode.preferredMode, and that field means the app's own default, which the
resolver deliberately places below the host's configured streamDisplayMode. So the
row went on reading Selected against the mode you had just moved away from, and the
choice only appeared to stick in the window before client settings had loaded, which
is why it looked like staleness and flip-flopped between visits.
The hierarchy itself is right and two tests pin it, so this does not reorder anything.
The contract keeps being written exactly as before, so Polaris still learns the
preference. What is new is a per-game override kept on this client and consulted above
the host default, written only by someone actually choosing in that destination. A game
nobody has chosen for resolves exactly as it did.
Verified on the Retroid: choosing writes launch_mode_override_, the row updates
without leaving the destination, and it still reads the same after a cold start, which
is the case that used to disagree with itself.
---
.../papi/nova/ui/NovaGameDetailActivity.kt | 4 ++-
.../com/papi/nova/ui/NovaGameDetailUiState.kt | 14 +++++++-
.../papi/nova/ui/NovaLaunchModeOverrides.kt | 34 +++++++++++++++++++
3 files changed, 50 insertions(+), 2 deletions(-)
create mode 100644 app/src/main/java/com/papi/nova/ui/NovaLaunchModeOverrides.kt
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
index 7b631a6e..420914d7 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt
@@ -415,6 +415,7 @@ class NovaGameDetailActivity : NovaActivity() {
allowedModes = allowedModes
)
currentGame = currentGame.copy(launchMode = updatedLaunchMode)
+ NovaLaunchModeOverrides.save(this@NovaGameDetailActivity, currentGame, mode)
refreshUiState()
optimizationState = NovaGameDetailOptimizationState()
loadOptimization(profilePreference, usesVirtualDisplay = mode == "virtual_display")
@@ -817,7 +818,8 @@ class NovaGameDetailActivity : NovaActivity() {
game = game,
defaultToVirtualDisplay = defaultToVirtualDisplay,
clientSettings = clientSettings,
- profilePreference = profilePreference
+ profilePreference = profilePreference,
+ launchModeOverride = NovaLaunchModeOverrides.load(this@NovaGameDetailActivity, game),
)
}
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt
index a86ef644..0286dc0f 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt
@@ -43,10 +43,22 @@ data class NovaGameDetailUiState(
game: PolarisGame,
defaultToVirtualDisplay: Boolean,
clientSettings: PolarisClientSettings?,
- profilePreference: String
+ profilePreference: String,
+ /** A mode chosen for this game on this client, which outranks the host default. */
+ launchModeOverride: String? = null,
): NovaGameDetailUiState {
val choice = game.resolveLaunchModeChoice(defaultToVirtualDisplay, clientSettings)
+ // Only a deliberate choice reaches here, so it answers before the host does.
+ // The contract's preferredMode stays below the host, as it means the app's own
+ // default rather than anyone's decision.
+ val chosen = launchModeOverride
+ ?.takeIf { it.isNotBlank() }
+ ?.let { PolarisGame.resolveLaunchMode(it, choice.headlessAllowed, choice.virtualDisplayAllowed) }
val playMode = when {
+ chosen == "virtual_display" && choice.virtualDisplayAllowed &&
+ !choice.virtualDisplayUnavailable -> "virtual_display"
+ chosen == "headless" && choice.headlessAllowed -> "headless"
+
choice.recommendedMode == "virtual_display" && choice.virtualDisplayAllowed -> "virtual_display"
choice.recommendedMode == "headless" && choice.headlessAllowed -> "headless"
choice.headlessAllowed -> "headless"
diff --git a/app/src/main/java/com/papi/nova/ui/NovaLaunchModeOverrides.kt b/app/src/main/java/com/papi/nova/ui/NovaLaunchModeOverrides.kt
new file mode 100644
index 00000000..3db15b84
--- /dev/null
+++ b/app/src/main/java/com/papi/nova/ui/NovaLaunchModeOverrides.kt
@@ -0,0 +1,34 @@
+package com.papi.nova.ui
+
+import android.content.Context
+import com.papi.nova.shared.polaris.model.PolarisGame
+
+/**
+ * Where the launch mode you choose for a game is remembered.
+ *
+ * It cannot live in the game's launch contract: preferredMode there means the app's own
+ * default and is deliberately outranked by the host's configured display mode, so a
+ * choice written into it is resolved away. This is a separate, higher answer to the same
+ * question — what this client should do for this game — and it is only ever written by
+ * choosing in the Launch Mode destination.
+ */
+object NovaLaunchModeOverrides {
+
+ private const val PREFS_NAME = "nova_prefs"
+ private const val KEY_PREFIX = "launch_mode_override_"
+
+ private fun key(game: PolarisGame): String =
+ KEY_PREFIX + game.id.ifBlank { game.appId.toString() }
+
+ fun load(context: Context, game: PolarisGame): String? =
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .getString(key(game), null)
+ ?.takeIf { it.isNotBlank() }
+
+ fun save(context: Context, game: PolarisGame, mode: String) {
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putString(key(game), mode)
+ .apply()
+ }
+}
From d52a2770d12dc22e6b95a26717c189ce9958b72b Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Thu, 6 Aug 2026 00:02:25 -0400
Subject: [PATCH 16/24] chore(nova): drop imports the sheet left behind
Splitting the bottom sheet into an activity and its content moved the fragment,
dialog and coroutine machinery out but left their imports where they were, and
Android lint does not fail on those. Forty-four of them across the two drawer files,
each confirmed unused by the compiler rather than by eye.
The two delegation imports that carry no textual reference, getValue and setValue,
are deliberately kept: by mutableStateOf needs them and nothing names them.
---
.../com/papi/nova/ui/NovaGameDetailContent.kt | 39 -------------------
.../nova/ui/NovaGameDetailDestinations.kt | 5 ---
2 files changed, 44 deletions(-)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
index 1f21168d..1f12812f 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
@@ -1,21 +1,9 @@
package com.papi.nova.ui
-import android.app.Dialog
-import android.content.Context
-import android.content.res.Configuration
-import android.os.Bundle
-import android.text.format.DateUtils
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
import android.widget.ImageView
-import android.widget.ScrollView
-import android.widget.Toast
-import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.focusGroup
@@ -26,11 +14,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.aspectRatio
-import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -38,7 +23,6 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
@@ -46,7 +30,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -66,8 +49,6 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.ComposeView
-import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
@@ -76,41 +57,21 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
-import androidx.core.content.ContextCompat
-import androidx.core.widget.NestedScrollView
import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.lifecycleScope
-import com.google.android.material.bottomsheet.BottomSheetBehavior
-import com.google.android.material.bottomsheet.BottomSheetDialog
-import com.google.android.material.bottomsheet.BottomSheetDialogFragment
-import com.papi.nova.LimeLog
import com.papi.nova.R
import com.papi.nova.api.PolarisApiClient
import com.papi.nova.api.PolarisArtworkChoice
import com.papi.nova.api.PolarisArtworkMatchCandidate
-import com.papi.nova.api.PolarisClientSettings
-import com.papi.nova.api.PolarisStreamDisplayMode
import com.papi.nova.shared.polaris.model.PolarisGame
-import com.papi.nova.manager.StreamSyncManager
-import com.papi.nova.preferences.PreferenceConfiguration
import com.papi.nova.ui.compose.LocalNovaComposeColors
import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces
import com.papi.nova.ui.compose.LocalNovaMenuOpacityScale
import com.papi.nova.ui.compose.NovaActionButton
import com.papi.nova.ui.compose.NovaBadge
-import com.papi.nova.ui.compose.NovaComposeTheme
import com.papi.nova.ui.compose.NovaControllerHint
-import com.papi.nova.ui.compose.NovaControllerHintBar
import com.papi.nova.ui.compose.NovaFocusableCard
-import com.papi.nova.utils.DeviceUtils
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
import org.json.JSONObject
-import java.util.Locale
-import kotlin.math.abs
-import kotlin.math.round
internal fun canPublishArtworkMutationUiForState(state: Lifecycle.State?): Boolean =
state?.isAtLeast(Lifecycle.State.CREATED) == true
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
index 505edac2..3de94ccb 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
@@ -23,19 +22,16 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
-import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
-import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
@@ -69,7 +65,6 @@ import com.papi.nova.ui.compose.LocalNovaComposeColors
import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces
import com.papi.nova.ui.compose.NovaControllerHint
import com.papi.nova.ui.compose.NovaControllerHintBar
-import com.papi.nova.ui.compose.NovaFocusableCard
/** The three ways a launch can go when Polaris reports desktop Steam active. */
internal enum class NovaSteamLaunchChoice {
From 04904812dcddd03665b4b311ff74be830519802c Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Thu, 6 Aug 2026 00:03:51 -0400
Subject: [PATCH 17/24] test(nova): make the drawer order guards assert
existence, not just order
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two assertions I re-pointed tonight compared indexes with a bare greater-than. That
passes vacuously when the left-hand string disappears, since a missing needle returns
-1 and any real position beats it — which is exactly how the MangoHUD guard came to be
asserting nothing once the composable it referenced was deleted.
Both move to the in-0-until form already used elsewhere in this file, which says both
things exist and one precedes the other.
---
.../java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
index 3124a629..469cebc2 100644
--- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
+++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt
@@ -1127,7 +1127,8 @@ class NovaComposeSourceGuardTest {
detail.contains("private fun showLaunchOptions(") &&
detail.contains("onLaunchOptions = {") &&
launchControls.contains("label = launchOptionsLabel") &&
- launchControls.indexOf("label = launchOptionsLabel") > launchControls.indexOf("label = headlessModeLabel")
+ launchControls.indexOf("label = headlessModeLabel") in
+ 0 until launchControls.indexOf("label = launchOptionsLabel")
)
assertTrue(
"launch mode should answer where it runs and leave the profile alone: preference, summary and reset are Tune's",
@@ -1164,7 +1165,8 @@ class NovaComposeSourceGuardTest {
"when MangoHUD is already enabled, the drawer should show only a passive status after launch controls",
sheetContent.contains("if (mangoHudEnabled) {") &&
sheetContent.contains("MangoHudPassiveStatus(") &&
- sheetContent.indexOf("MangoHudPassiveStatus(") > sheetContent.indexOf("LaunchControls(")
+ sheetContent.indexOf("LaunchControls(") in
+ 0 until sheetContent.indexOf("MangoHudPassiveStatus(")
)
}
From 8f17f7f28031825d421be630520a0ac75391cfa0 Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Thu, 6 Aug 2026 00:11:30 -0400
Subject: [PATCH 18/24] feat(nova): let the drawer rows reach the panel's edges
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The drawer was full width but its rows were not. The panel padded its whole column by
the 28dp inset and then again by the safe area, so every divider, focus tint and accent
bar stopped about 57dp short of each edge. The list read as a narrow column floating on
a wide empty surface, which is the gap on either side.
The inset moves off the body and onto the things that want it — header, hint bar, group
labels, prose, the notice — so a row spans the panel and carries its own inset for its
text. The safe area splits by axis with it: a cutout must not eat text, but it need not
stop a row background from reaching the edge it is drawn against, so the panel keeps the
vertical inset and everything holding text takes the horizontal one.
The focus bar now starts at the edge it is meant to mark, and the hairline separates the
whole width rather than a column of it.
---
.../com/papi/nova/ui/NovaGameDetailContent.kt | 3 ++
.../nova/ui/NovaGameDetailDestinations.kt | 29 +++++++++++++++----
2 files changed, 26 insertions(+), 6 deletions(-)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
index 1f12812f..830ce222 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
@@ -1032,6 +1032,7 @@ private fun LaunchControls(
Column {
Text(
text = launchIntro,
+ modifier = Modifier.padding(horizontal = NovaGameDetailInset),
color = if (uiState.virtualDisplayUnavailable) colors.warning else colors.textSecondary,
fontSize = 11.sp,
lineHeight = 14.sp,
@@ -1140,6 +1141,7 @@ internal fun LaunchProfilePrimaryNotice(
Column(
modifier = Modifier
.fillMaxWidth()
+ .padding(horizontal = NovaGameDetailInset)
.padding(top = 8.dp)
.clip(RoundedCornerShape(12.dp))
.background(toneColor.copy(alpha = 0.14f))
@@ -1482,6 +1484,7 @@ private fun InsightCard(card: NovaGameDetailInsightCard) {
Box(
modifier = Modifier
.fillMaxWidth()
+ .padding(horizontal = NovaGameDetailInset)
.padding(top = 12.dp, bottom = 2.dp),
) {
Column {
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
index 3de94ccb..5a84c9fd 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt
@@ -13,11 +13,13 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.width
@@ -114,8 +116,10 @@ internal fun NovaGameDetailPanel(
.background(surfaces.panel)
// Taps inside the panel are not taps outside it.
.novaDismissOnTap {}
- .windowInsetsPadding(WindowInsets.safeContent)
- .padding(horizontal = NovaGameDetailInset, vertical = if (shortViewport) 10.dp else 20.dp)
+ // Vertical only. A cutout must not eat text, but it need not stop a row
+ // background from reaching the edge it is drawn against.
+ .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Vertical))
+ .padding(vertical = if (shortViewport) 10.dp else 20.dp)
.testTag("nova-game-detail-panel"),
) {
NovaGameDetailDestinationHeader(
@@ -252,7 +256,11 @@ private fun NovaGameDetailDestinationHints() {
),
),
compact = true,
- modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Horizontal))
+ .padding(horizontal = NovaGameDetailInset)
+ .padding(top = 10.dp),
)
}
@@ -267,7 +275,11 @@ private fun NovaGameDetailDestinationHeader(
val colors = LocalNovaComposeColors.current
Row(
verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth().padding(bottom = if (compact) 6.dp else 14.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Horizontal))
+ .padding(horizontal = NovaGameDetailInset)
+ .padding(bottom = if (compact) 6.dp else 14.dp),
) {
Column(modifier = Modifier.weight(1f)) {
if (!compact) {
@@ -341,7 +353,11 @@ internal fun NovaGameDetailGroupLabel(text: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
- modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 6.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Horizontal))
+ .padding(horizontal = NovaGameDetailInset)
+ .padding(top = 16.dp, bottom = 6.dp),
) {
Text(
text = text.uppercase(),
@@ -518,7 +534,8 @@ internal fun NovaSteamChoiceRow(
size = Size(size.width, 1f),
)
}
- .padding(start = 12.dp, end = 2.dp, top = 9.dp, bottom = 9.dp)
+ .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Horizontal))
+ .padding(start = NovaGameDetailInset, end = NovaGameDetailInset, top = 9.dp, bottom = 9.dp)
.semantics { contentDescription = if (value.isBlank()) label else "$label. $value" },
) {
Column(modifier = Modifier.weight(1f)) {
From ad1e89c88ca35ae8b00f06e9ad38c8b9078e19b2 Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Thu, 6 Aug 2026 08:58:36 -0400
Subject: [PATCH 19/24] feat(nova): carry the launcher playtime the host now
reports
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Polaris reads minutes from Steam localconfig.vdf and serves them as
playtime_minutes. Nova carries it through the model and the adapter, clamped at
zero so a host that says something impossible reads the same as one that says
nothing: no duration to show.
The field sits last in the constructor rather than beside lastLaunched where it
reads best. Sixteen places build PolarisGame positionally, and a parameter inserted
into the middle shifts every one of them silently — the nvstream contract test
caught it by passing a Boolean where a Long had just appeared. Serialisation is by
name, so the position costs nothing.
No UI yet: the gauge waits until a host is actually serving the field, so it can be
built against a real number rather than a zero.
---
.../papi/nova/api/PolarisGameJsonAdapter.kt | 1 +
.../nova/api/PolarisApiClientParsingTest.kt | 19 +++++++++++++++++++
.../nova/shared/polaris/model/PolarisGame.kt | 10 +++++++++-
3 files changed, 29 insertions(+), 1 deletion(-)
diff --git a/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt b/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt
index 214bf197..006e10f9 100644
--- a/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt
+++ b/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt
@@ -43,6 +43,7 @@ object PolarisGameJsonAdapter {
coverUrl = json.optString("cover_url", ""),
genres = fetchStringArray(json.optJSONArray("genres")),
lastLaunched = json.optLong("last_launched", 0),
+ playtimeMinutes = json.optLong("playtime_minutes", 0).coerceAtLeast(0),
mangohud = json.optBoolean("mangohud", false),
hdrSupported = json.optBoolean("hdr_supported", false),
launchMode = launchMode,
diff --git a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt
index 7b10f013..44404265 100644
--- a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt
+++ b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt
@@ -991,4 +991,23 @@ class PolarisApiClientParsingTest {
assertEquals("big-picture", body.getString("mode"))
}
+ @Test
+ fun playtimeMinutesAreCarriedFromTheHostAndNeverNegative() {
+ val game = PolarisGameJsonAdapter.fromJson(
+ JSONObject(
+ """{id:abc,app_id:1,name:Control,playtime_minutes:1684}"""
+ )
+ )
+ assertEquals(1684L, game.playtimeMinutes)
+
+ // A host that says nothing, and one that says something impossible, both mean
+ // there is no duration to show rather than a negative one.
+ val silent = PolarisGameJsonAdapter.fromJson(JSONObject("""{id:abc,name:Control}"""))
+ assertEquals(0L, silent.playtimeMinutes)
+
+ val nonsense = PolarisGameJsonAdapter.fromJson(
+ JSONObject("""{id:abc,name:Control,playtime_minutes:-5}""")
+ )
+ assertEquals(0L, nonsense.playtimeMinutes)
+ }
}
diff --git a/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt b/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt
index c6e6eb6c..7605ae39 100644
--- a/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt
+++ b/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt
@@ -26,7 +26,15 @@ data class PolarisGame(
@SerialName("launch_mode") val launchMode: LaunchModeContract? = null,
@SerialName("steam_launch") val steamLaunch: SteamLaunchContract? = null,
@SerialName("display_planner") val displayPlanner: DisplayPlannerContract? = null,
- @SerialName("artwork") val artwork: ArtworkManifest? = null
+ @SerialName("artwork") val artwork: ArtworkManifest? = null,
+ /**
+ * Minutes the owning launcher says this has been played; 0 when nothing local knows.
+ *
+ * Last in the list on purpose. Sixteen places build this positionally, so a field
+ * inserted beside lastLaunched where it reads best would silently shift every one of
+ * them. Serialisation is by name, so position costs nothing here.
+ */
+ @SerialName("playtime_minutes") val playtimeMinutes: Long = 0
) {
@Serializable
data class ArtworkManifest(
From c8ac25a14463f56d9c3dd7512adf4f9dc0d04b1d Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Thu, 6 Aug 2026 09:28:26 -0400
Subject: [PATCH 20/24] feat(nova): show how long a game has been played
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Polaris serves play_time now, read from the launcher that owns the game rather than
accumulated from streamed sessions — a game with two hundred hours at the desk would
otherwise read as however long it had been streamed to a handheld.
Carried as an object rather than a number, and nullable rather than zeroed, because the
concept is explicit that absent is not zero: a game nobody has played and a game no
launcher can speak for are different answers, and only one of them should ever read
"Not started".
The concept draws a gauge whose notches are the three How Long To Beat estimates. None
exist yet, and it says what to draw in that case — the played figure alone, no bar,
since there is nothing to measure against. So that is what this is, and the bar arrives
with the data rather than ahead of it. It sits under the hairline that divides names
from numbers, because a duration is a number.
The field is last in the constructor rather than beside lastLaunched where it reads
best. Sixteen places build PolarisGame positionally, and a parameter inserted into the
middle shifts every one of them silently; the nvstream contract test caught it by
passing a Boolean where a Long had just appeared.
Verified end to end on the Retroid against a live host: Phasmophobia reads 199 h,
matching the 11983 minutes in this machine's localconfig.vdf.
---
.../papi/nova/api/PolarisGameJsonAdapter.kt | 8 +++++-
.../papi/nova/ui/NovaGameDetailOverview.kt | 25 +++++++++++++++++++
app/src/main/res/values/strings.xml | 2 ++
.../nova/api/PolarisApiClientParsingTest.kt | 25 +++++++++++--------
.../nova/shared/polaris/model/PolarisGame.kt | 15 +++++++++--
5 files changed, 61 insertions(+), 14 deletions(-)
diff --git a/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt b/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt
index 006e10f9..9fa7fc7e 100644
--- a/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt
+++ b/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt
@@ -43,7 +43,13 @@ object PolarisGameJsonAdapter {
coverUrl = json.optString("cover_url", ""),
genres = fetchStringArray(json.optJSONArray("genres")),
lastLaunched = json.optLong("last_launched", 0),
- playtimeMinutes = json.optLong("playtime_minutes", 0).coerceAtLeast(0),
+ playTime = json.optJSONObject("play_time")?.let { played ->
+ PolarisGame.PlayTime(
+ seconds = played.optLong("seconds", 0).coerceAtLeast(0),
+ source = played.optString("source", ""),
+ readAt = played.optLong("read_at", 0).coerceAtLeast(0)
+ )
+ },
mangohud = json.optBoolean("mangohud", false),
hdrSupported = json.optBoolean("hdr_supported", false),
launchMode = launchMode,
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
index df184599..3de521f2 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
@@ -178,6 +178,31 @@ internal fun NovaGameDetailOverview(
),
)
+ // A duration is a number, so it sits below the hairline with the rest of them.
+ // The concept's gauge needs How Long To Beat to have something to measure
+ // against; until that exists it draws the played figure alone, and a game no
+ // launcher owns draws nothing rather than claiming nobody has played it.
+ uiState.game.playTime?.takeIf { it.seconds > 0 }?.let { played ->
+ val minutes = played.seconds / 60
+ Text(
+ text = if (minutes >= 60) {
+ stringResource(R.string.nova_game_detail_played_hours, minutes / 60)
+ } else {
+ stringResource(R.string.nova_game_detail_played_minutes, minutes)
+ },
+ color = colors.textSecondary,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.17.em,
+ maxLines = 1,
+ // these are measurements, so the digits line up rather than dance
+ style = LocalTextStyle.current.copy(fontFeatureSettings = "tnum"),
+ modifier = Modifier
+ .padding(top = 9.dp)
+ .testTag("nova-game-detail-played"),
+ )
+ }
+
NovaGameDetailStatusLine(
uiState = uiState,
optimizationState = optimizationState,
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 77be5501..a7e27e47 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -271,6 +271,8 @@
Actions
Insight
Close
+ %1$d h played
+ %1$d min played
Streams without touching the physical desktop
A dedicated display on the host, at your resolution
Resolution, bitrate and codec for this launch
diff --git a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt
index 44404265..a37dbd38 100644
--- a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt
+++ b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt
@@ -992,22 +992,25 @@ class PolarisApiClientParsingTest {
}
@Test
- fun playtimeMinutesAreCarriedFromTheHostAndNeverNegative() {
- val game = PolarisGameJsonAdapter.fromJson(
+ fun playTimeIsCarriedFromTheHostAndAbsentIsNotZero() {
+ val played = PolarisGameJsonAdapter.fromJson(
JSONObject(
- """{id:abc,app_id:1,name:Control,playtime_minutes:1684}"""
+ """{id:abc,name:Control,play_time:{seconds:143520,source:steam,read_at:1754470000}}"""
)
)
- assertEquals(1684L, game.playtimeMinutes)
+ assertEquals(143520L, played.playTime?.seconds)
+ assertEquals("steam", played.playTime?.source)
- // A host that says nothing, and one that says something impossible, both mean
- // there is no duration to show rather than a negative one.
- val silent = PolarisGameJsonAdapter.fromJson(JSONObject("""{id:abc,name:Control}"""))
- assertEquals(0L, silent.playtimeMinutes)
+ // No launcher owns the answer: null, so the gauge can be omitted rather than
+ // drawn claiming nobody has played it.
+ val unowned = PolarisGameJsonAdapter.fromJson(JSONObject("""{id:abc,name:Control}"""))
+ assertNull(unowned.playTime)
- val nonsense = PolarisGameJsonAdapter.fromJson(
- JSONObject("""{id:abc,name:Control,playtime_minutes:-5}""")
+ // Owned, but never played, is a different answer and keeps its object.
+ val untouched = PolarisGameJsonAdapter.fromJson(
+ JSONObject("""{id:abc,name:Control,play_time:{seconds:0,source:steam}}""")
)
- assertEquals(0L, nonsense.playtimeMinutes)
+ assertNotNull(untouched.playTime)
+ assertEquals(0L, untouched.playTime?.seconds)
}
}
diff --git a/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt b/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt
index 7605ae39..690d8722 100644
--- a/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt
+++ b/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt
@@ -28,13 +28,16 @@ data class PolarisGame(
@SerialName("display_planner") val displayPlanner: DisplayPlannerContract? = null,
@SerialName("artwork") val artwork: ArtworkManifest? = null,
/**
- * Minutes the owning launcher says this has been played; 0 when nothing local knows.
+ * How long the owning launcher says this has been played, or null when none can say.
+ *
+ * Null rather than zero: a game nobody has played and a game no launcher owns are
+ * different answers, and only one of them should read "Not started".
*
* Last in the list on purpose. Sixteen places build this positionally, so a field
* inserted beside lastLaunched where it reads best would silently shift every one of
* them. Serialisation is by name, so position costs nothing here.
*/
- @SerialName("playtime_minutes") val playtimeMinutes: Long = 0
+ @SerialName("play_time") val playTime: PlayTime? = null
) {
@Serializable
data class ArtworkManifest(
@@ -49,6 +52,14 @@ data class PolarisGame(
fun asset(kind: String): ArtworkAsset? = assets.asset(kind)
}
+ /** What a launcher says about time spent, normalised to seconds before it travels. */
+ @Serializable
+ data class PlayTime(
+ @SerialName("seconds") val seconds: Long = 0,
+ @SerialName("source") val source: String = "",
+ @SerialName("read_at") val readAt: Long = 0
+ )
+
@Serializable
data class ArtworkMatch(
@SerialName("source") val source: String = "",
From 821795d944a4222a4233bb9cd559c811b3d467f8 Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Thu, 6 Aug 2026 09:44:01 -0400
Subject: [PATCH 21/24] feat(nova): draw the play time gauge against the
completion estimates
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Polaris serves beat_time now, matched from a dataset on the host rather than fetched
from a third party at runtime. With both halves present the Overview draws what the
concept drew: played hours at the left, the three estimates at the right, and a bar
whose full width is the completionist figure with notches where main and extras fall.
The partial cases are the point, and each is the concept's own answer. Played with no
estimates keeps the figure alone, because there is nothing to measure it against.
Estimates with nothing played read "Not started" over an empty track. Neither draws
nothing at all, and the hairline above still separates identity from machine state.
Played past the end caps the bar and lets the figure keep counting, since the hours you
actually spent are the honest number even when they run off the end of someone else's
estimate.
Every figure is optional on its own: a catalogue that knows the main story but not the
completionist run says so rather than padding the gap with a zero, and the bar falls
back to the longest figure it actually has.
Verified on the Retroid against a live host: Phasmophobia reads 199 h played against
6 · 20 · 50 h to beat, with the bar capped and both notches at the twelve and forty
percent marks the estimates put them at.
---
.../papi/nova/api/PolarisGameJsonAdapter.kt | 10 ++
.../papi/nova/ui/NovaGameDetailOverview.kt | 138 +++++++++++++++---
app/src/main/res/values/strings.xml | 2 +
.../nova/shared/polaris/model/PolarisGame.kt | 24 ++-
4 files changed, 153 insertions(+), 21 deletions(-)
diff --git a/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt b/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt
index 9fa7fc7e..e51652c8 100644
--- a/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt
+++ b/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt
@@ -43,6 +43,16 @@ object PolarisGameJsonAdapter {
coverUrl = json.optString("cover_url", ""),
genres = fetchStringArray(json.optJSONArray("genres")),
lastLaunched = json.optLong("last_launched", 0),
+ beatTime = json.optJSONObject("beat_time")?.let { beat ->
+ PolarisGame.BeatTime(
+ mainSeconds = beat.optLong("main_seconds", 0).coerceAtLeast(0),
+ extrasSeconds = beat.optLong("extras_seconds", 0).coerceAtLeast(0),
+ completionistSeconds = beat.optLong("completionist_seconds", 0).coerceAtLeast(0),
+ matchedName = beat.optString("matched_name", ""),
+ url = beat.optString("url", ""),
+ cachedAt = beat.optLong("cached_at", 0).coerceAtLeast(0)
+ )
+ },
playTime = json.optJSONObject("play_time")?.let { played ->
PolarisGame.PlayTime(
seconds = played.optLong("seconds", 0).coerceAtLeast(0),
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
index 3de521f2..aaf77a2f 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
@@ -1,6 +1,7 @@
package com.papi.nova.ui
import android.widget.ImageView
+import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -43,6 +44,8 @@ import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
@@ -182,26 +185,10 @@ internal fun NovaGameDetailOverview(
// The concept's gauge needs How Long To Beat to have something to measure
// against; until that exists it draws the played figure alone, and a game no
// launcher owns draws nothing rather than claiming nobody has played it.
- uiState.game.playTime?.takeIf { it.seconds > 0 }?.let { played ->
- val minutes = played.seconds / 60
- Text(
- text = if (minutes >= 60) {
- stringResource(R.string.nova_game_detail_played_hours, minutes / 60)
- } else {
- stringResource(R.string.nova_game_detail_played_minutes, minutes)
- },
- color = colors.textSecondary,
- fontSize = 11.sp,
- fontWeight = FontWeight.SemiBold,
- letterSpacing = 0.17.em,
- maxLines = 1,
- // these are measurements, so the digits line up rather than dance
- style = LocalTextStyle.current.copy(fontFeatureSettings = "tnum"),
- modifier = Modifier
- .padding(top = 9.dp)
- .testTag("nova-game-detail-played"),
- )
- }
+ NovaGameDetailBeatGauge(
+ playTime = uiState.game.playTime,
+ beatTime = uiState.game.beatTime,
+ )
NovaGameDetailStatusLine(
uiState = uiState,
@@ -489,6 +476,117 @@ private fun NovaGameDetailActions(
}
}
+/**
+ * How long this has been played, against how long it takes.
+ *
+ * The concept is explicit about what each partial case draws, because a gauge that
+ * guesses is worse than one that says less:
+ *
+ * - both a bar whose full width is the completionist figure, notched where main
+ * and extras fall, played hours at the left and the estimates at the right
+ * - played only the played figure alone; there is nothing to measure it against
+ * - estimate only "Not started", and the notches on an empty track
+ * - neither nothing at all, and the hairline above still separates identity from state
+ * - past the end the bar caps and the figure keeps counting, since that is the true number
+ */
+@Composable
+private fun NovaGameDetailBeatGauge(
+ playTime: PolarisGame.PlayTime?,
+ beatTime: PolarisGame.BeatTime?,
+) {
+ val colors = LocalNovaComposeColors.current
+ val surfaces = LocalNovaLibrarySurfaces.current
+
+ val playedSeconds = playTime?.seconds?.takeIf { it > 0 } ?: 0L
+ val fullWidthSeconds = beatTime?.longestSeconds?.takeIf { it > 0 } ?: 0L
+ if (playedSeconds <= 0L && fullWidthSeconds <= 0L) {
+ return
+ }
+
+ val figures = LocalTextStyle.current.copy(fontFeatureSettings = "tnum")
+
+ Column(modifier = Modifier.padding(top = 9.dp).testTag("nova-game-detail-played")) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.width(NOVA_GAUGE_WIDTH),
+ ) {
+ Text(
+ text = if (playedSeconds > 0L) {
+ val minutes = playedSeconds / 60
+ if (minutes >= 60) {
+ stringResource(R.string.nova_game_detail_played_hours, minutes / 60)
+ } else {
+ stringResource(R.string.nova_game_detail_played_minutes, minutes)
+ }
+ } else {
+ stringResource(R.string.nova_game_detail_not_started)
+ },
+ color = colors.textSecondary,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.17.em,
+ maxLines = 1,
+ style = figures,
+ modifier = Modifier.weight(1f),
+ )
+
+ if (beatTime != null) {
+ val estimates = listOf(
+ beatTime.mainSeconds,
+ beatTime.extrasSeconds,
+ beatTime.completionistSeconds,
+ ).filter { it > 0 }.joinToString(" · ") { "${it / 3600}" }
+
+ if (estimates.isNotEmpty()) {
+ Text(
+ text = stringResource(R.string.nova_game_detail_to_beat, estimates),
+ color = colors.textMuted,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.17.em,
+ maxLines = 1,
+ style = figures,
+ )
+ }
+ }
+ }
+
+ if (fullWidthSeconds > 0L) {
+ val track = surfaces.tileBorder
+ val fill = colors.accent
+ val notchInk = colors.textMuted
+ val played = (playedSeconds.toFloat() / fullWidthSeconds.toFloat()).coerceIn(0f, 1f)
+ val notches = listOfNotNull(
+ beatTime?.mainSeconds?.takeIf { it > 0 && it < fullWidthSeconds },
+ beatTime?.extrasSeconds?.takeIf { it > 0 && it < fullWidthSeconds },
+ ).map { it.toFloat() / fullWidthSeconds.toFloat() }
+
+ Canvas(
+ modifier = Modifier
+ .padding(top = 6.dp)
+ .width(NOVA_GAUGE_WIDTH)
+ .height(4.dp),
+ ) {
+ drawRect(color = track, size = size)
+ if (played > 0f) {
+ drawRect(color = fill, size = Size(size.width * played, size.height))
+ }
+ notches.forEach { fraction ->
+ drawRect(
+ color = notchInk,
+ topLeft = Offset(size.width * fraction, 0f),
+ size = Size(NOVA_GAUGE_NOTCH.toPx(), size.height),
+ )
+ }
+ }
+ }
+ }
+}
+
+/** Matches the hairline above it, so the two read as one column. */
+private val NOVA_GAUGE_WIDTH = 330.dp
+private val NOVA_GAUGE_NOTCH = 2.dp
+
/** Dissolves the hero band into the window colour instead of cutting against it. */
private fun Modifier.novaFadeToGround(ground: Color): Modifier = drawWithContent {
drawContent()
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a7e27e47..77bef67c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -271,6 +271,8 @@
Actions
Insight
Close
+ Not started
+ %1$s h to beat
%1$d h played
%1$d min played
Streams without touching the physical desktop
diff --git a/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt b/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt
index 690d8722..9fdc2a0f 100644
--- a/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt
+++ b/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt
@@ -37,7 +37,9 @@ data class PolarisGame(
* inserted beside lastLaunched where it reads best would silently shift every one of
* them. Serialisation is by name, so position costs nothing here.
*/
- @SerialName("play_time") val playTime: PlayTime? = null
+ @SerialName("play_time") val playTime: PlayTime? = null,
+ /** Completion estimates, or null when the host's dataset has nothing for this game. */
+ @SerialName("beat_time") val beatTime: BeatTime? = null
) {
@Serializable
data class ArtworkManifest(
@@ -52,6 +54,26 @@ data class PolarisGame(
fun asset(kind: String): ArtworkAsset? = assets.asset(kind)
}
+ /**
+ * What the host's dataset says about finishing this game.
+ *
+ * Every figure is optional on its own: a catalogue that knows the main story but not
+ * the completionist run should say so rather than pad the gap with a zero.
+ */
+ @Serializable
+ data class BeatTime(
+ @SerialName("main_seconds") val mainSeconds: Long = 0,
+ @SerialName("extras_seconds") val extrasSeconds: Long = 0,
+ @SerialName("completionist_seconds") val completionistSeconds: Long = 0,
+ @SerialName("matched_name") val matchedName: String = "",
+ @SerialName("url") val url: String = "",
+ @SerialName("cached_at") val cachedAt: Long = 0
+ ) {
+ /** The bar's full width, falling back through what is actually known. */
+ val longestSeconds: Long
+ get() = maxOf(completionistSeconds, extrasSeconds, mainSeconds)
+ }
+
/** What a launcher says about time spent, normalised to seconds before it travels. */
@Serializable
data class PlayTime(
From 1da4555a3a2eb3b6b589521dda598a8465b44200 Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Thu, 6 Aug 2026 10:06:53 -0400
Subject: [PATCH 22/24] fix(nova): draw the gauge the way the concept draws it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The first pass was written from the concept's prose — played hours left, estimates
right — which got the arrangement right and everything else wrong. The drawing says
more than the sentence did.
Each estimate is labelled, because three bare numbers do not say which is which. The
played figure is the bright one and the estimates are dimmer, so the row has an order to
read it in. Both are uppercase at the tracking the rest of the identity block uses. The
bar is rounded and filled with a gradient rather than flat, and the notches are cut
through it in the ground colour with a light ring, overhanging top and bottom — which is
the difference between a notch in the bar and a mark painted on it, and is visible only
in the drawing.
The estimate also becomes a control when the dataset carries a page for it: padded,
rounded, and in the focus lane with the accent ring. Without a page it is only something
to read, and stays out of the lane rather than offering a link to nowhere.
Verified on the Retroid against live data: Phasmophobia reads 199 h played against
main 20 h, extras 41 h and 100% 138 h, with the bar capped and both notches placed.
---
.../papi/nova/ui/NovaGameDetailOverview.kt | 158 ++++++++++++++----
app/src/main/res/values/strings.xml | 4 +-
2 files changed, 124 insertions(+), 38 deletions(-)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
index aaf77a2f..8815fc55 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
@@ -2,6 +2,7 @@ package com.papi.nova.ui
import android.widget.ImageView
import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.focusable
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -36,6 +37,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.key
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.semantics.Role
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -46,6 +49,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
@@ -479,15 +483,20 @@ private fun NovaGameDetailActions(
/**
* How long this has been played, against how long it takes.
*
- * The concept is explicit about what each partial case draws, because a gauge that
- * guesses is worse than one that says less:
+ * Drawn as the concept draws it: the played figure bright at the left, the three
+ * estimates dimmer at the right and labelled so a number means something, and a bar
+ * whose full width is the completionist figure with the other two cut through it as
+ * notches — ground-coloured with a light ring, overhanging top and bottom, so they read
+ * as gaps in the bar rather than marks on top of it.
*
- * - both a bar whose full width is the completionist figure, notched where main
- * and extras fall, played hours at the left and the estimates at the right
- * - played only the played figure alone; there is nothing to measure it against
- * - estimate only "Not started", and the notches on an empty track
- * - neither nothing at all, and the hairline above still separates identity from state
- * - past the end the bar caps and the figure keeps counting, since that is the true number
+ * The partial cases are the concept's own answers, because a gauge that guesses is worse
+ * than one that says less:
+ *
+ * - both bar, notches, figure and estimates
+ * - played only the figure alone; there is nothing to measure it against
+ * - estimate only "Not started" over an empty track
+ * - neither nothing, and the hairline above still separates identity from state
+ * - past the end the bar caps and the figure keeps counting, which is the true number
*/
@Composable
private fun NovaGameDetailBeatGauge(
@@ -496,6 +505,7 @@ private fun NovaGameDetailBeatGauge(
) {
val colors = LocalNovaComposeColors.current
val surfaces = LocalNovaLibrarySurfaces.current
+ val uriHandler = LocalUriHandler.current
val playedSeconds = playTime?.seconds?.takeIf { it > 0 } ?: 0L
val fullWidthSeconds = beatTime?.longestSeconds?.takeIf { it > 0 } ?: 0L
@@ -504,8 +514,9 @@ private fun NovaGameDetailBeatGauge(
}
val figures = LocalTextStyle.current.copy(fontFeatureSettings = "tnum")
+ var estimateFocused by remember { mutableStateOf(false) }
- Column(modifier = Modifier.padding(top = 9.dp).testTag("nova-game-detail-played")) {
+ Column(modifier = Modifier.padding(top = 10.dp).testTag("nova-game-detail-played")) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.width(NOVA_GAUGE_WIDTH),
@@ -520,41 +531,84 @@ private fun NovaGameDetailBeatGauge(
}
} else {
stringResource(R.string.nova_game_detail_not_started)
- },
- color = colors.textSecondary,
- fontSize = 11.sp,
- fontWeight = FontWeight.SemiBold,
- letterSpacing = 0.17.em,
+ }.uppercase(),
+ color = colors.textPrimary,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 0.12.em,
maxLines = 1,
style = figures,
- modifier = Modifier.weight(1f),
)
- if (beatTime != null) {
- val estimates = listOf(
- beatTime.mainSeconds,
- beatTime.extrasSeconds,
- beatTime.completionistSeconds,
- ).filter { it > 0 }.joinToString(" · ") { "${it / 3600}" }
+ Spacer(modifier = Modifier.weight(1f))
+
+ val parts = listOfNotNull(
+ beatTime?.mainSeconds?.takeIf { it > 0 }
+ ?.let { stringResource(R.string.nova_game_detail_beat_main, it / 3600) },
+ beatTime?.extrasSeconds?.takeIf { it > 0 }
+ ?.let { stringResource(R.string.nova_game_detail_beat_extras, it / 3600) },
+ beatTime?.completionistSeconds?.takeIf { it > 0 }
+ ?.let { stringResource(R.string.nova_game_detail_beat_complete, it / 3600) },
+ )
+
+ if (parts.isNotEmpty()) {
+ val page = beatTime?.url.orEmpty()
+ val linked = page.isNotBlank()
+ val ring = if (estimateFocused && linked) colors.accent else Color.Transparent
- if (estimates.isNotEmpty()) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier
+ .clip(RoundedCornerShape(NOVA_GAUGE_CHIP_RADIUS))
+ .background(
+ if (estimateFocused && linked) {
+ colors.accent.copy(alpha = 0.14f)
+ } else {
+ Color.Transparent
+ }
+ )
+ .border(1.dp, ring, RoundedCornerShape(NOVA_GAUGE_CHIP_RADIUS))
+ .then(
+ // A page makes this a control; without one it is a readout and
+ // has no business in the focus lane.
+ if (linked) {
+ Modifier
+ .onFocusChanged { estimateFocused = it.isFocused || it.hasFocus }
+ .clickable(role = Role.Button) { uriHandler.openUri(page) }
+ .focusable()
+ } else {
+ Modifier
+ }
+ )
+ .padding(horizontal = 7.dp, vertical = 3.dp),
+ ) {
Text(
- text = stringResource(R.string.nova_game_detail_to_beat, estimates),
- color = colors.textMuted,
- fontSize = 11.sp,
- fontWeight = FontWeight.SemiBold,
- letterSpacing = 0.17.em,
+ text = parts.joinToString(" · ").uppercase(),
+ color = colors.textSecondary,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Medium,
+ letterSpacing = 0.10.em,
maxLines = 1,
style = figures,
)
+ if (linked) {
+ Text(
+ text = "\u2197",
+ color = if (estimateFocused) colors.accent else colors.textMuted,
+ fontSize = 10.sp,
+ )
+ }
}
}
}
if (fullWidthSeconds > 0L) {
- val track = surfaces.tileBorder
- val fill = colors.accent
- val notchInk = colors.textMuted
+ val track = colors.textPrimary.copy(alpha = 0.10f)
+ val fillEnd = colors.accent
+ val fillStart = lerp(colors.accent, Color.White, 0.28f)
+ val notchInk = colors.window
+ val notchRing = colors.textPrimary.copy(alpha = 0.34f)
val played = (playedSeconds.toFloat() / fullWidthSeconds.toFloat()).coerceIn(0f, 1f)
val notches = listOfNotNull(
beatTime?.mainSeconds?.takeIf { it > 0 && it < fullWidthSeconds },
@@ -565,17 +619,43 @@ private fun NovaGameDetailBeatGauge(
modifier = Modifier
.padding(top = 6.dp)
.width(NOVA_GAUGE_WIDTH)
- .height(4.dp),
+ .height(NOVA_GAUGE_BAR + NOVA_GAUGE_NOTCH_OVERHANG * 2),
) {
- drawRect(color = track, size = size)
+ val barTop = NOVA_GAUGE_NOTCH_OVERHANG.toPx()
+ val barHeight = NOVA_GAUGE_BAR.toPx()
+ val radius = CornerRadius(barHeight / 2f, barHeight / 2f)
+
+ drawRoundRect(
+ color = track,
+ topLeft = Offset(0f, barTop),
+ size = Size(size.width, barHeight),
+ cornerRadius = radius,
+ )
if (played > 0f) {
- drawRect(color = fill, size = Size(size.width * played, size.height))
+ drawRoundRect(
+ brush = Brush.horizontalGradient(listOf(fillStart, fillEnd)),
+ topLeft = Offset(0f, barTop),
+ size = Size(size.width * played, barHeight),
+ cornerRadius = radius,
+ )
}
+
+ val notchWidth = NOVA_GAUGE_NOTCH.toPx()
+ val ringWidth = notchWidth + 2f
notches.forEach { fraction ->
- drawRect(
+ val centre = size.width * fraction
+ // The ring first, so the notch reads as a gap cut through the bar.
+ drawRoundRect(
+ color = notchRing,
+ topLeft = Offset(centre - ringWidth / 2f, 0f),
+ size = Size(ringWidth, size.height),
+ cornerRadius = CornerRadius(ringWidth / 2f, ringWidth / 2f),
+ )
+ drawRoundRect(
color = notchInk,
- topLeft = Offset(size.width * fraction, 0f),
- size = Size(NOVA_GAUGE_NOTCH.toPx(), size.height),
+ topLeft = Offset(centre - notchWidth / 2f, 1f),
+ size = Size(notchWidth, size.height - 2f),
+ cornerRadius = CornerRadius(notchWidth / 2f, notchWidth / 2f),
)
}
}
@@ -585,7 +665,11 @@ private fun NovaGameDetailBeatGauge(
/** Matches the hairline above it, so the two read as one column. */
private val NOVA_GAUGE_WIDTH = 330.dp
-private val NOVA_GAUGE_NOTCH = 2.dp
+private val NOVA_GAUGE_BAR = 4.dp
+private val NOVA_GAUGE_NOTCH = 1.5.dp
+/** The notches overhang the bar, which is what makes them read as cuts through it. */
+private val NOVA_GAUGE_NOTCH_OVERHANG = 2.dp
+private val NOVA_GAUGE_CHIP_RADIUS = 5.dp
/** Dissolves the hero band into the window colour instead of cutting against it. */
private fun Modifier.novaFadeToGround(ground: Color): Modifier = drawWithContent {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 77bef67c..3a2d4419 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -272,7 +272,9 @@
Insight
Close
Not started
- %1$s h to beat
+ Main %1$d h
+ Extras %1$d h
+ 100%% %1$d h
%1$d h played
%1$d min played
Streams without touching the physical desktop
From 0ac6c6fcd18588148fbea0db8f4f0430504495bf Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Thu, 6 Aug 2026 10:22:13 -0400
Subject: [PATCH 23/24] feat(nova): say which game an estimate is actually for
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The estimate comes from a fuzzy match against a catalogue, and a wrong match looks
exactly like a right one: a plausible number, presented as fact. The concept carries
matched_name for this reason — so a bad match is visible and correctable, the way an
artwork match already is.
Shown only when the names genuinely differ. Punctuation and case disagree constantly
between a launcher and a catalogue, and "Control Ultimate Edition" against "Control:
Ultimate Edition" is the same answer; saying so would bury the cases that matter. A
different word or numeral is not the same answer, so "Slay the Spire 2" matched as
"Slay the Spire II" says so, and so would the day something matches the wrong game.
Verified on the Retroid: Slay the Spire 2 reads its estimate with the line, Control
carries the same difference in the dataset and correctly stays quiet.
---
.../papi/nova/ui/NovaGameDetailOverview.kt | 32 +++++++++++++++++++
app/src/main/res/values/strings.xml | 1 +
2 files changed, 33 insertions(+)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
index 8815fc55..e67b641d 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt
@@ -190,6 +190,7 @@ internal fun NovaGameDetailOverview(
// against; until that exists it draws the played figure alone, and a game no
// launcher owns draws nothing rather than claiming nobody has played it.
NovaGameDetailBeatGauge(
+ gameName = uiState.game.name,
playTime = uiState.game.playTime,
beatTime = uiState.game.beatTime,
)
@@ -500,6 +501,7 @@ private fun NovaGameDetailActions(
*/
@Composable
private fun NovaGameDetailBeatGauge(
+ gameName: String,
playTime: PolarisGame.PlayTime?,
beatTime: PolarisGame.BeatTime?,
) {
@@ -660,7 +662,37 @@ private fun NovaGameDetailBeatGauge(
}
}
}
+
+ // A fuzzy match that went wrong looks exactly like one that went right, so the
+ // name it actually found is shown whenever it is not plainly the same game.
+ val matched = beatTime?.matchedName.orEmpty()
+ if (matched.isNotBlank() && novaSameTitle(matched, gameName).not()) {
+ Text(
+ text = stringResource(R.string.nova_game_detail_matched_as, matched),
+ color = colors.textMuted,
+ fontSize = 10.sp,
+ letterSpacing = 0.06.em,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .padding(top = 5.dp)
+ .width(NOVA_GAUGE_WIDTH),
+ )
+ }
+ }
+}
+
+/**
+ * Whether two titles are the same game as far as a reader cares.
+ *
+ * Punctuation and case disagree constantly between a launcher and a catalogue, and
+ * saying so every time would bury the cases that matter under noise.
+ */
+private fun novaSameTitle(left: String, right: String): Boolean {
+ fun fold(value: String) = buildString {
+ value.forEach { if (it.isLetterOrDigit()) append(it.lowercaseChar()) }
}
+ return fold(left) == fold(right)
}
/** Matches the hairline above it, so the two read as one column. */
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3a2d4419..9c400298 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -272,6 +272,7 @@
Insight
Close
Not started
+ Estimate for %1$s
Main %1$d h
Extras %1$d h
100%% %1$d h
From a0358ec210c0872b213745db09b1a271c73bd545 Mon Sep 17 00:00:00 2001
From: papi <20916260+papi-ux@users.noreply.github.com>
Date: Thu, 6 Aug 2026 10:37:28 -0400
Subject: [PATCH 24/24] fix(nova): stop the launch mode badge claiming a
recommendation nobody made
recommendedMode falls back to preferredMode when neither the host nor the game contract
says anything, so with nothing to go on it echoes the preference back. The badge then
lands on whichever mode is not selected, and moves when the selection moves.
What made it look random is that the host view arrives late. clientSettings starts null
and is only filled during a launch preflight, so a freshly opened window resolves against
the contract while a window that has already launched something resolves against the
host. Same game, two answers, decided by history the reader cannot see.
The badge now appears only when something actually recommended: the host said so, or the
contract did. An echo of the preference is not a recommendation and no longer claims to
be. The resolver is untouched, so the two tests pinning its hierarchy stay honest.
Verified on the Retroid: a freshly opened Launch Mode reads Selected and Available, where
it previously read Selected and Recommended for no reason it could point to.
---
app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt | 6 ++++--
app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt | 6 ++++++
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
index 830ce222..54e6560f 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt
@@ -1079,7 +1079,8 @@ private fun LaunchControls(
onClick = { onLaunchModeSelected("headless") },
value = when {
uiState.playMode == "headless" -> "Selected"
- uiState.recommendedMode == "headless" && uiState.headlessAllowed -> "Recommended"
+ uiState.hasRecommendation && uiState.recommendedMode == "headless" &&
+ uiState.headlessAllowed -> "Recommended"
uiState.headlessAllowed -> "Available"
else -> "Unavailable"
},
@@ -1096,7 +1097,8 @@ private fun LaunchControls(
value = when {
uiState.virtualDisplayUnavailable -> "Unavailable"
uiState.playMode == "virtual_display" -> "Selected"
- uiState.recommendedMode == "virtual_display" && uiState.virtualDisplayAllowed -> "Recommended"
+ uiState.hasRecommendation && uiState.recommendedMode == "virtual_display" &&
+ uiState.virtualDisplayAllowed -> "Recommended"
uiState.virtualDisplayAllowed -> "Available"
else -> "Unavailable"
},
diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt
index 0286dc0f..c5f8c1e8 100644
--- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt
+++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt
@@ -11,6 +11,8 @@ data class NovaGameDetailUiState(
val launchChoice: PolarisGame.LaunchModeChoice,
val preferredMode: String,
val recommendedMode: String,
+ /** Whether anything actually recommended it, rather than it echoing the preference. */
+ val hasRecommendation: Boolean,
val headlessAllowed: Boolean,
val virtualDisplayAllowed: Boolean,
val virtualDisplayUnavailable: Boolean,
@@ -96,6 +98,10 @@ data class NovaGameDetailUiState(
launchChoice = choice,
preferredMode = choice.preferredMode,
recommendedMode = choice.recommendedMode,
+ // The resolver falls back to the preference when neither the host nor
+ // the contract recommends anything, so ask whether either did.
+ hasRecommendation = choice.hostDefaultMode.isNotBlank() ||
+ !game.launchMode?.recommendedMode.isNullOrBlank(),
headlessAllowed = choice.headlessAllowed,
virtualDisplayAllowed = choice.virtualDisplayAllowed,
virtualDisplayUnavailable = choice.virtualDisplayUnavailable,