Skip to content

feat(nova): restructure the game detail window around its artwork - #188

Merged
papi-ux merged 24 commits into
masterfrom
nova/game-detail-cinematic
Aug 6, 2026
Merged

feat(nova): restructure the game detail window around its artwork#188
papi-ux merged 24 commits into
masterfrom
nova/game-detail-cinematic

Conversation

@papi-ux

@papi-ux papi-ux commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Summary

Stacked on #187, which moved the detail into its own window without changing how it looked. This changes how it looks.

The window opened as a single scroll column stacking ten concerns. The landing screen now answers one question — do I want to play this — and everything else has 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 200×64dp — the same asset the sheet crushed into 56%×46% 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:

Branch Was Now
desktopSteamDecision.required a bottom sheet over the content the Launch mode destination it belongs in
optimizationState.reviewRequired a raw AlertDialog.Builder the status line expanding in place
otherwise launches unchanged

Neither needed porting, because the design already had the right home for each. A choice of where to run belongs in the destination named that. The review is a statement about the profile, and the status line is where the profile lives — so it expands, and the alert's three buttons become the action lane. That removes the last raw AlertDialog in the detail, which the guards already forbade for launch options and profile preference.

Artwork takes the whole window. NovaArtworkStudio opens with a Row of weighted Columns and cannot fold into 60% of a landscape shell. Tune and Launch mode keep the panel.

singleTop, because a fast double-press on a poster otherwise stacks two windows.

Deliberately absent: the playtime/HLTB gauge, which has no source until Polaris reads it from the launchers. Session-aware verbs were absent for the same reason and are not any more: activeSession is plumbed in now, so the primary action follows the session — Resume when one is running, Watch when it is someone else's.

Two bugs the device caught that a mockup could not

  • Glass over artwork. Both destinations were built on surfaces.panel, which is sheet glass scaled again by the user's menu opacity — correct over a dimmed sheet, wrong over a hero. The entire Overview read straight through Artwork. Both now lay their glass over an opaque ground. Same class of mistake as the Portable Chrome button in the concept: a surface is not media.
  • The hint bar overlapped the action row, both being BottomStart. One column owns the floor now.

Also from device: the status line and the primary button both said Launch 120 FPS, so the most legible row was spending itself on a word the button underneath was about to say; and an X Launch Mode hint was showing for a button that is not bound to anything.

Guard tests

Nine failed, and none was a find-and-replace — several took the sheet's shape as their subject: a hint bar inside the scroll column, a footer composed outside it, artwork ordered below the insights, a 136dp hero in an identity panel. That shape is what this change removes. Each assertion is re-pointed at the structure that now carries the same guarantee, and two get stronger:

Guard Now asserts
composeBottomSheetsUseThemeAwareGlassHost… the window hosts no bottom sheet and no legacy alert at all
requiredNativeAlertsUseSharedOpacityAndBlurChrome the detail raises no legacy alert; the review expands in place
desktopSteamDecisionSheetUsesNovaGlass… the choice routes to LAUNCH_MODE, and a blocked option stays visible with its reason
gameDetailLaunchControlsPrioritizePrimaryPlayFocus Play holds first focus, the lane is one row, 48dp floor, nothing scrolls above it
gameDetailUsesHeroBackdropLogoTransform… full-bleed backdrop, logo as title at 200×64dp, no 136dp panel

The desktop-Steam routing cannot be exercised on hardware without a game running on the host desktop — Polaris's desktopGameDetection is polaris_running_app_only. For that path these assertions are the verification, which is what a source guard is for.

What followed, on the device

The window landed. The drawers inside it did not, and everything below came from using them on the
Retroid rather than from reading them.

The drawers were the wrong shape. They read as rounded bubbles floating in a padded column —
wide and barren at the same time, small text inside large boxes. The concept never asked for cards.
It specified 48dp rows in a 438dp lane, label left and value right, and focus shown as an inset
accent bar that does not move the row. So the cards go, and so does the two-column split that was
mine rather than the concept's: uneven column heights were what left the holes. The panel padding
went with it. A drawer was full width while its rows were not, every divider and focus tint stopping
about 57dp short of each edge, so the inset moved off the body and onto the things that want it, and
the safe area split by axis — a cutout must not eat text, but it need not stop a row background from
reaching the edge it is drawn against.

Nothing could reach the drawers by d-pad. The only requestFocus in the window was the
Overview's play button, so a destination opened with focus nowhere and the d-pad moved nothing at
all — on a handheld whose primary input is a d-pad. Once focus had somewhere to land it could also
leave: pressing left walked out of the panel onto the Overview's Launch Mode button, dimmed behind
the scrim and one press from acting. The body is a focus group now, and the Overview is scenery
while a destination is open.

Launch mode was answering the wrong question. It said its own name three times — header, a card
titled Launch Mode, and a badge repeating what the mode pills already said — then spent the rest of
the panel on AI Preference, the Launch Profile summary and Reset Game Profile. Those are Tune's, and
Tune holds them now. Removing the duplicate took the only entry to the profile preference picker
with it, so that moves to the head of Tune's State, where the concept puts it.

Choosing a launch mode did nothing. It wrote into launchMode.preferredMode, which means the
app's own default and sits below the host's streamDisplayMode in resolution — so the row went on
reading Selected against the mode you had just moved away from, and appeared to stick only in the
window before client settings loaded. The hierarchy is right and two tests pin it, so this does not
reorder anything: the contract is still written, Polaris still learns the preference, and a per-game
override kept on the client is consulted above the host default. A game nobody has chosen for
resolves exactly as before.

Three the themes caught, and one measurement that lied

The theme sweep ran twice, because the first predates the rows.

  • The scrim was theme-tinted. Dimming with the window colour is only dimming while the window is
    dark; under Portable Chrome the same expression painted a white veil, washing the hero to flat grey
    and taking the identity line with it. A scrim is a shadow rather than a surface, so it stays dark.
  • The focus tint inverted. A fixed alpha is not a fixed strength: the focused row shifted 39
    points per channel under Portable Chrome against 7 to 22 everywhere else. The first fix asked
    colors.window and silently never fired, because that theme takes its lightness from
    surfaces.panel layered over the window — the wrong ground to ask. Reading polarity from the text
    colour brings it to 29, inside the 19 to 33 the other five sit in. Worth recording that the visual
    check could not tell 39 from 29; only measuring the pixels could.
  • The heads-up badge was amber on amber, painting its label in the tone colour over a ground
    already tinted with that tone. Illegible in light themes, and only visible at all once launch mode
    could be opened. A badge is a surface rather than media, so it takes an opaque fill with ink chosen
    from that tone's own luminance.

Two traps in the guards themselves

gameDetailKeepsMangoHudOutOfPrimaryLaunchDrawer had quietly stopped asserting anything: it compared
indexOf against a composable that no longer existed, and a missing needle returns -1, which any real
position beats. Two assertions re-pointed during this work had the same shape and were rewritten to
the in 0 until form used elsewhere in that file, which says both things exist and one precedes the
other.

Separately, 44 imports left behind by #187's split of the sheet into an activity and its content were
removed — each confirmed unused by the compiler rather than by eye, and the two delegation imports
that carry no textual reference deliberately kept.

Exact candidate

Verification

Every commit on the branch was taken through the full suite before it was pushed.

  • :app:testNonRoot_gameDebugUnitTest — 1,170 tests, 0 failures
  • :app:lintNonRoot_gameDebug -PlintFailOnError=true — CI's exact invocation
  • :app:assembleNonRoot_gameDebug / Release / DebugAndroidTest
  • CI green on the head commit: assemble release, analyze, CodeQL, lint and unit test, public-hygiene
  • full instrumented suite on the Retroid: 36 tests, 33 pass

Three instrumented tests fail — NovaLibraryActivityComposeTest (both shell tests) and
NovaPolarisSyncSheetComposeTest. They fail identically when checked out at c43ba29a, the head
this branch had before any of the above, so they are pre-existing rather than caused here. The cause
looks environmental: connectedAndroidTest reinstalls the app, which wipes pairing, and all three
assert on chrome that only exists once a host is paired — Search this library and the sync sheet's
Desktop Display. Nothing in this branch touches either.

On the physical Retroid Pocket 6 against a live host, in landscape:

  • all six themes on the rebuilt rows — Polaris, OLED, Miami, High Contrast, Portable Chrome, Material You
  • focus tint measured per theme rather than eyeballed; the outlier is now inside the band
  • d-pad: a destination takes focus on entry, down moves within a group, right crosses to the next,
    and four presses of left stay inside the panel instead of falling through to the screen behind
  • focus stops at the last row rather than escaping to the scrim or panel, which are tap targets
  • tapping beside the panel returns to the Overview; Close returns from the full-screen studio
  • Tune: State, Actions and Insight in one lane, the actions above the fold, provenance reading to
    its end, the explanation finishing its sentence
  • Launch mode reached for the first time, after loading evdi on the host: both modes and the
    settings row read as one list, choosing returns to the Overview as drawn
  • the launch mode override survives a cold start, which is the case that used to disagree with itself
  • the artwork studio opens expanded in its own window, refreshes against live providers, and the
    library's posters survive the round trip
  • a game with no genre renders its identity line without a dangling separator

Not verified on hardware

  • The poster fallback path. Every one of the 18 titles on this host has Polaris-fetched hero art,
    including the Steam entry. The mechanism is pinned by two guards — the backdrop's
    artworkKind = if (hasCachedHero) with both loaders, and the Overview reusing that backdrop — but
    nothing exercised it for real.
  • Host Virtual actually streaming. It fails with error 503, because Polaris probes for a display
    backend at startup and evdi was loaded after the running daemon had already looked. The capability
    is advertised, so the destination appears; the daemon needs a restart to use it. That the launch
    attempted it at all is the evidence the override reaches the launch.
  • The desktop-Steam decision and the expanded preflight review, as before: guards rather than hands.

Known, and not addressed here

  • Selecting a launch mode does not refresh the host's own recommendation, so the Recommended badge can
    land on whichever mode is not selected and change between visits. Unexplained.
  • The library hero's button is labelled Launch but its action is hard-paired with OPEN_DETAIL, so it
    opens this window rather than launching. Pre-existing, and library-side rather than this window's.
  • The launch intro can contradict itself, naming one mode as the app default and then describing the other.

Resolves the open note on #187

That PR flagged one launch resolving to a different game than the window had open, unexplained. It
reproduced here: launching one game returned a session for another because a resumable session for
the second existed on the host, and the Moonlight protocol resumes an existing session rather than
starting a new app. Host and protocol behaviour, not the result contract.

@papi-ux

papi-ux commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Fidelity pass — 61e8a2df

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 — and I called it done because the structure matched what I had specced, without reading the concept's own type and glyph rails back.

Was Now
identity line Steam · Played 17 min. ago · Action STEAM · PLAYED 17 MIN. AGO · ACTION — uppercase, .17em
status readout sentence case uppercase, .11em, tabular figures
primary action flat colors.accent, no glyph accent gradient carrying its bound button
secondary actions bare slabs hairline-bordered, marked
floor bordered hint container on the artwork borderless, ✦ POLARIS opposite the back hint

Figures are tabular because they are measurements and should line up rather than dance. The floor is borderless because a bordered container there 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 became its own composable.

Re-verified: 1,170 unit tests, lintNonRoot_gameDebug -PlintFailOnError=true, both release assemblies, instrumented test on the Retroid, and hands-on with Control Ultimate Edition.

@papi-ux
papi-ux changed the base branch from nova/game-detail-window to master August 5, 2026 22:48
papi-ux added 3 commits August 5, 2026 18:48
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.
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.
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.
@papi-ux
papi-ux force-pushed the nova/game-detail-cinematic branch from d5f7469 to 4925620 Compare August 5, 2026 22:49
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-ux

papi-ux commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

The verb follows the session — e1a4f3a2

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 holds the api client, so it can see the session and derive the verb — Watch when another device owns it, Resume when you do, plus End session.

Resuming and ending need stream credentials the window does not carry, so it returns the intent and the library performs it — the contract the launch already uses.

A finding that only the device could give

The derivation was initially unreachable. While a session is live the library drops that game from the grid — 20 shown became 18 — and surfaces it only in the resume row, whose card resumed along with its button. So the running game was the one entry in the whole library 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 for every other hero state. One line, and it makes the running game inspectable for the first time.

Verified against a live Control session

window requests endlibrary got session=endre-query → Control Ultimate EditionendActiveSession → its confirmation → Control exits on the host. Diagnostics removed before commit.

Two guards restated, neither weakened: the hero card assertion now describes opening the game with a fallback to the primary action, and the detail's primary is filled unless it is Watch — the only action available on someone else's session is not the action you came for, and a filled control would overstate it.

papi-ux added 20 commits August 5, 2026 20:26
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.
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.
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.
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.
…rofile

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.
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.
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.
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.
…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.
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.
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_<uuid>, 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.
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.
…rder

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.
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.
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.
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.
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.
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.
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.
…y 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.
@papi-ux
papi-ux merged commit d47fb15 into master Aug 6, 2026
6 checks passed
@papi-ux
papi-ux deleted the nova/game-detail-cinematic branch August 6, 2026 14:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant