How the desktop surface adapts Steam's game page¶
How the desktop surface — the surface Tender draws into Steam's desktop client — adapts Steam's game page for a RomM shortcut, and gives every change back when the page leaves it.
Dev build only. Nothing on this page is in a shipped bundle. The surface reaches Steam only through
pnpm -C frontend build:desktop (the desktop dev build).
The navigation watcher (frontend/src/desktop/navigationWatcher.ts) does the work on this page. It draws what it is
handed — a DesktopGamePage with an optional gameView and playButton — and startDesktopSurface
(frontend/src/desktop/index.ts) is the one place that hands it anything. A part it is handed replaces Steam's own:
gameView the page's content sections, playButton Steam's Play button and the badges beside it. A part left out
leaves Steam's own in place, and the play bar, the hero and the controls beside them are adapted either way.
It hands both. GameView (frontend/src/desktop/gameview/GameView.tsx) draws the page's cards in two columns. Its one
card so far is About — the cover Steam shows (steamsCover, frontend/src/utils/artworkThumbs.ts), the title, the
summary and the rows metadataRows answers, under the labels and in the order Big Picture's GAME INFO tab shows them.
While it is drawn it holds two things RomMPlaySection holds on Big Picture's game page: the connection heartbeat, and
the automatic artwork, whose apply in flight it cancels on leaving.
While a RetroDECK migration is pending it draws the migration card
(frontend/src/desktop/gameview/MigrationBlockedCard.tsx) in place of the columns, as the game page's section does on
Big Picture (RomMGameInfoPanel), and goes on holding the heartbeat and the automatic artwork behind it. The card's
sentence names Big Picture as where the Tender menu is, because the desktop client has no Quick Access menu. It asks the
backend for the migration state again for each game page it is drawn for (useMigrationRefresh,
frontend/src/utils/migrationRefresh.ts).
PlayButton (frontend/src/desktop/gameview/PlayButton.tsx) is Tender's Play button in the desktop client. Its presses
(frontend/src/desktop/gameview/usePlayLaunch.ts) are those of Big Picture's CustomPlayButton
(frontend/src/bigpicture/CustomPlayButton.tsx): PLAY starts the game through the launch gate
(Pre-launch sync), STOP does what Big Picture's Stop Game menu entry
does, and RESOLVE CONFLICT what its Resolve Conflict button does. It has no RESUME: while the game runs, whatever
started it, the button is STOP, as Steam's own Play button in the desktop client is (seen on CachyOS running Steam
client build 1792618285), and bringing the game's window to the front is the window manager's. It asks its questions in
the desktop dialogs, which useDialogHost (frontend/src/desktop/gameview/dialogs/useDialogHost.tsx) draws into the
body of the document the button is drawn in. Its save-tracking step is the one the
Pre-launch sync table names for it. Where the game cannot be played
from the page — not downloaded, downloading, or its downloaded file gone — it draws a disabled button that says which,
and where the game cannot start — its emulator cannot, or its file does not start with it — PLAY is disabled with the
sentence startRefusal (frontend/src/utils/emulatorStart.ts) answers. While the page checks whether the game can
start, and where that check went wrong, PLAY does what Big Picture's does
(Launch-target validation), and reads CHECKING... where Big
Picture's shows its throbber. On a stranded panel it gates as the invariant register's
stranded-panel entry says. Steam's Play button and the badges beside it are hidden as soon as the button's root has
rendered, also while it draws nothing — before the page's detail names the ROM, and on a stranded panel — and the button
draws no badges in their place.
DiscSelector (frontend/src/desktop/gameview/DiscSelector.tsx) sits beside the Play button, one control where Big
Picture has two beside CustomPlayButton, frontend/src/bigpicture/DiscSelector.tsx and
frontend/src/bigpicture/VersionPicker.tsx: those are two gamepad focus stops, each with a menu of its own, and one
mouse menu holds both lists. Its menu lists a multi-disc game's discs, then the versions of the game's group. What it
reads and what a pick does are theirs (frontend/src/utils/discSelection.ts, frontend/src/utils/versionSwitch.ts),
and a version's row says what Big Picture's says (frontend/src/utils/versionPickerWording.ts). A version gone from
RomM is listed and offers nothing: Big Picture's way to its removed-game cleanup from the picker has no desktop
counterpart. A switch that would leave unsynced saves behind asks in a desktop dialog. The selector draws nothing where
the button draws nothing, and leaves the discs out while the bound version's file is missing. While a switch is under
way its trigger is disabled and dimmed, with the throbber in place of its face, and PLAY and RESOLVE CONFLICT are
disabled, with the throbber left of their labels and no pointer cursor (qam-panel.md says what
draws it there); while the Play button's check is under way its trigger is disabled and dimmed and an open menu closes,
since the two ask through one dialog host, where a newer question answers the open one dismissed.
Neither the Play button nor the selector adds a stylesheet to Steam's document, so nothing changes on hover on either.
Two windows, two realms¶
The panel runs in Steam's SharedJSContext, where the backend loads it
(loading the panel), and the navigation watcher runs
there with it. The page it adapts is in another window: the popup that g_PopupManager.GetPopups() lists under a name
starting with SP Desktop (findDesktopWindow, frontend/src/desktop/desktopWindow.ts). The watcher looks for that
window when it starts, on a poll, and whenever Steam reports a popup created or destroyed; it detaches from a window
that closed or was replaced, and attaches to the new one. A window it could not attach to is not tried again.
That window's document is a realm of its own, as the Quick Access view's is. What that means for instanceof and for a
constructor such as MutationObserver or ResizeObserver is the trap in CLAUDE.md, "instanceof against a DOM
global is false in QAM and desktop-client code".
Which window is Steam's main UI¶
Measured in Steam's SharedJSContext on a Steam Deck in Desktop Mode. The Big Picture reading (4, no SP Desktop
popup) was taken again on a Steam Deck running Steam client build 1792618285.
SteamUIStore.MainInstanceUIModereads 7 while the desktop client is the main window (SP Desktop_uid0) and 4 while Big Picture is (SP BPM_uid0), and reads 7 again after leaving Big Picture.- Steam replaces the main window rather than layering one over the other: while Big Picture was open, no
SP Desktoppopup was ing_PopupManager.GetPopups(), and after returning a newSP Desktop_uid0window existed. A reference to the desktop window does not outlive a trip through Big Picture, which is why the navigation watcher re-finds it. - With the desktop client closed to the tray, the mode stays 7, the
SP Desktop_uid0popup still exists,MainWindowVisibleis true and the window'sdocument.hiddenis false. From JavaScript, a window hidden in the tray cannot be told apart from an open one. - Game Mode was not measured. Tender reads every mode other than 7, and an unreadable
SteamUIStore, as the desktop client not being the main UI.
Dialogs for a start the launch watcher catches¶
A start Tender's Play button did not make — Steam's own Play, a steam://rungameid link — is caught by the launch
watcher (frontend/src/utils/launchInterceptor.ts), which may have questions to ask before the game starts. Which
surface draws them is decided by launchPromptsForThisStart (frontend/src/utils/launchPromptRouter.ts): the desktop
dialogs while MainInstanceUIMode is 7 and the desktop surface has offered them and has an open window, and Steam's
gamepad modals otherwise. The desktop surface offers them from startDesktopSurface and withdraws them from
stopDesktopSurface (frontend/src/desktop/index.ts), so a bundle without the desktop surface — every shipped one —
always asks through the gamepad modals.
Such a start comes with no Tender component to ask from, so askInDesktopWindow
(frontend/src/desktop/launchPromptHost.tsx) draws each question into the body of the desktop window that exists at the
moment it is asked: a container created with that window's own document.createElement, and a React root from
findReactClient. Each question keeps a DomRestorationLedger of its own, which records the root, its container and
the window's pagehide and unload listeners, and restoreAll() takes all of it away when the question settles. A
question settles with the button pressed, or with its dismissed answer: unlogged when the dialog is left
(DesktopDialog's onDismiss), and logged as a desktop: warning or error naming why when its window goes or unloads,
when the surface is withdrawn, when there is no open desktop window or createRoot to draw with, or when looking for
them or drawing it throws. A dismissal the host makes itself does not wait for a request the dialog has in flight: when
the save-conflict dialog's window goes, or the surface is withdrawn, while its resolution is running, the start is
cancelled, the resolution still lands and shows its toast, and its answer, arriving after the question settled, is
logged as a desktop: warning and dropped, so the announcement a start makes once its conflicts are resolved
(announceSaveSync) is not made. Two starts caught together each draw a dialog of their own, one over the other, and
Escape then dismisses both.
Known limitation: because a tray-hidden desktop window looks open, a question for a start caught while the desktop client sits in the tray is drawn into a window nobody sees until Steam is opened again, and the start waits for it.
Fiber, read-only (frontend/src/desktop/watcher/fiberInspector.ts)¶
Where the DOM does not say which element is which, the watcher reads React's Fiber: the __reactFiber$ (or
__reactInternalInstance$) property React attaches to a DOM element. From it, it reads a component's name (its
displayName or type.name, walking up past HTML tags to the nearest component), and an appId from the nearest
ancestor's props — appId, overview.appid or details.appid — when the route (/library/app/<appId> in
MainWindowBrowserManager.m_lastLocation.pathname) yields none.
It reads Fiber and never writes it, because the desktop client offers no seam to write through: a patch on a context
menu's menuContent.type fires zero times, since each open builds a fresh element, and the desktop route passes no
renderFunc to patch (#1866). The types it reads Fiber through are readonly, so the compiler refuses a write.
The restoration ledger (frontend/src/desktop/watcher/restorationLedger.ts)¶
Every change the navigation watcher makes to Steam's page goes through one DomRestorationLedger, so that leaving the
page gives all of it back:
- Styles:
ledger.style(el, property, value)records the element's own inline value of that property, named in its CSS spelling, before the first change; later changes keep that first record. - Hiding:
ledger.hide(el)records the element's inlinedisplaybefore the first hide and sets it tonone; a later hide keeps the first record. - React roots:
ledger.recordRoot(root, host)records a mounted root with the host element Tender created for it, andledger.releaseRoot(host)unmounts one root and removes its host. - Listeners:
ledger.addListener(...)attaches a listener and records it.
ledger.restoreAll() detaches the listeners, unmounts the roots and removes their hosts, then restores the recorded
displays and styles, in that order; a step that throws is logged and the rest still run. It runs when the page leaves
RomM shortcuts or moves to another RomM game — whenever the ledger holds anything, not only once a page was adapted, so
a pass that threw partway is given back too — when a pass tears down a stale GameView (below), when a view fails to
mount or fails after it was drawn, and when the watcher detaches from the window. A callback registered with Steam can
still fire after that, and the ledger cannot cancel what it never recorded: the watcher's popup callbacks, a view's
render callback and the sticky play bar controller's updates each do nothing once the teardown before them has run. The
controller's own set-up is no exception: when its first update throws, it disconnects its ResizeObserver before the
throw reaches the pass.
A launch question keeps a ledger of its own, whose changes are given back when the question settles ("Dialogs for a start the launch watcher catches").
A test holds the whole rule for the navigation watcher: it records the style attribute of every element on a page,
runs passes from Steam's mutations and from a change to the set of RomM shortcuts, re-pins the play bar on a scroll and
a resize, leaves the page, and requires every attribute to be what it was
(frontend/src/desktop/navigationWatcher.test.ts, "every change it makes is given back"). That a pass which threw
partway is given back is held by its own test, "gives back what a pass changed before it threw".
The selector ladder (frontend/src/desktop/watcher/elementSelectors.ts)¶
A class name in Steam's DOM is a webpack hash that a Steam update may change, so no selector names one, and ESLint fails
a string or regex literal under frontend/src/desktop/ that holds a token shaped like one
(frontend/eslint.config.js). The finders resolve through these rungs instead:
| Rung | What it matches | Example |
|---|---|---|
| module class | The exact class @decky/ui resolved from Steam's own modules (frontend/src/utils/deckyUiInternals.ts) |
appDetailsClasses.PlayBar, appActionButtonClasses.PlayButtonContainer |
| class name | The readable, PascalCase part of a module class name, where the class carries one | [class*="PlayBar"], [class*="AppDetailsOverviewPanel"] |
| Fiber name | The nearest component name on the element's Fiber | the nearest name is PlayButton or AppActionButton |
| structure | Known parent and child relationships | The nearest common ancestor of the play bar and the content sections |
What is fixed is the two ends: the module class is asked first, and the structural walk last. Between them a finder asks
the rungs it has, in its own order, and not every finder has all four. Three do not stop at the first answer:
findSteamPlaySection asks the Play button's ladder before its own and climbs from the button it finds, and
findSteamContentSections and findSteamPlayBarBadges collect from every rung. The name the watcher logs for a rung is
the one in the table (Rung).
The module class is not one rung among equals. The class-name rung needs a readable part, and not every class has one:
on a German-language desktop client, as the maintainer measured it (#2389), the Play button's and the play bar's classes
are two hashes and Panel, and the module class is the only rung that finds either. Text and ARIA labels are no rung at
all, because they read differently in every language.
frontend/src/desktop/watcher/elementSelectors.moduleClasses.test.ts holds the first rung against made-up hashed class
maps, since the rest of the suite runs with those maps undefined.
Drawing a view¶
A part is drawn in a React root of its own, in a host element Tender creates beside Steam's: the playButton before
Steam's Play button, the gameView right after the play bar. createRoot comes from Steam's module registry
(findReactClient, frontend/src/desktop/desktopWindow.ts). The watcher looks it up the first time it has a view to
mount and keeps the answer, a miss included, for as long as it is attached to that window; with nothing handed to it, it
never asks.
Steam's own part is hidden only once React has committed the view into its host, so a view that fails to draw leaves
Steam's in place. A mount that fails — createRoot or render throwing, or the view throwing as it renders — is logged
and remembered for that appId, and not tried again, and the next pass gives Steam's page back first: an earlier mount of
the same part, before Steam moved its Play button or the view broke, may have hidden Steam's, and it returns. Steam's
part stays from then on.
The pass order¶
Steam renders the game page in more than one go: the hero's blurred background canvases, the play bar's badges and
Steam's duplicate sticky header arrive after the page's first render. An adaptation made only once, when the page is
first seen, misses all of them. Until the canvases arrive, findInflatedHeroWrapper — which recognises the hero wrapper
by the canvases inflating its scrollHeight — finds nothing, and the watcher keeps overflow: visible on what
findHeroWrapperFallback answers instead: the panel's first element with Hero in its class name, else the panel's
first child unless that child holds the play bar.
So each pass (pass() in frontend/src/desktop/navigationWatcher.ts) runs in this order:
- Another game's page is a page left. A pass on a different appId than the one before it gives Steam's page back first, as leaving RomM shortcuts would. Where Steam keeps the same Play button and badges across the switch, what was hidden for the last game would otherwise stay hidden when nothing is drawn for this one.
- A stale
GameViewis torn down. One carrying another appId, or no longer in the insertion container, is unmounted with everything the ledger holds. Both of these come before the adaptations because they restore everything the ledger holds: after them, they would undo them all. - The adaptations, on every pass, in this order: hide the page's content sections (once a
gameViewis drawn); mountplayButtonbeside Steam's Play button, and hide Steam's once it is drawn — remounted only when it is missing, detached, for another appId or moved; set up or refresh the sticky play bar controller, which also keeps the hero wrapper atoverflow: visible(below); hide Steam's duplicate sticky header; hide the play bar's badges (once aplayButtonis drawn); push the right-hand controls to the right edge. - The mount guard. If a
GameViewfor this appId is already in place, or failed for it before, the pass ends here. Otherwise it mounts one right after the play bar.
The adaptations run on every pass, before the mount guard, because Steam adds the canvases after GameView has mounted
— a pass that returned at the guard would skip them.
A pass runs in the desktop window's next animation frame, and every trigger that arrives before that frame shares it: at most one pass a frame. Passes are triggered by:
- A
MutationObserveron the desktop window'sbody, for elements added or removed anywhere in its subtree. - A change to the set of RomM shortcuts (
onRomMAppIdsChanged,frontend/src/utils/rommAppIds.ts), so a page opened before its shortcut was registered is adapted once it is. - A view's first render, which is what hides the Steam part it replaces, and a view's failure, which gives that part back (below, "Drawing a view").
- Attaching to a window, for the page already open in it.
The sticky play bar controller (frontend/src/desktop/watcher/stickyPlayBarController.ts) also updates on scroll, on
the window's resize, and on a ResizeObserver over the play bar, the scroller, the content container and the parallax
layer — the sizes boundHeroOverflow's margin is computed from (below).
Neither sets a timer. A timer catches only a change that adds or removes no element. On a Steam Deck's desktop client
(2026-10-14, #2389), the watcher's interval and settle timeouts ran some 800 passes of their own and changed nothing;
the sticky play bar controller's settle timeouts changed one thing, the parallax layer's clip margin, which follows
sizes rather than elements. So the ResizeObserver watches those sizes in their place, and with it watching them,
checks one and three seconds after each play bar was set up found nothing left to change.
What it logs¶
The watcher writes to the backend's log, each line starting desktop:: its failures as errors and warnings, which the
default log level keeps, and its transitions as info, which only Tender's log level at Info or Debug keeps. It logs each
transition once: attaching to and detaching from the desktop client's window, adapting a RomM game page — with the rung
that found the panel, the play bar and the Play button — and giving the page back. It logs every failure with what it
was doing: a window it could not attach to, a pass that threw (once for each path and error), a view that could not be
drawn, a restore step that threw, and createRoot missing from Steam's modules.
A RomM game page the finders never recognised is a warning too, logged once when the page is left and naming what was not found, the overview panel or the play section, and how long the page was open. It is the line to look for after a Steam update. It is not logged when a pass misses the page, because a pass that runs before Steam has drawn the page misses it too, and that miss is no failure. A page left before Steam drew it, as when clicking quickly through the library, was never recognised either, so it is reported too; the time it was open tells the two apart, since a page the finders cannot read goes unrecognised for as long as it is open.
The hero's parallax¶
Steam's game page scrolls its hero artwork more slowly than the page, behind the play bar and the cards, with CSS 3D transforms:
- The scroller sets a
perspective. - The hero wrapper and the elements between it and the artwork keep
transform-style: preserve-3dandoverflow: visible. - The artwork's own layer — the parallax layer — is pushed back along the z axis and scaled up to its visual size
again (a
matrix3d(...)transform), which slows its scroll. It sits on a negativez-index, which paints it beneath the page's other content.
The sticky play bar controller keeps the play bar see-through while it scrolls with the page, so the artwork shows
through it, and solid once it is pinned to the top of the scroller (isPlayBarPinned); its colours are in
frontend/src/desktop/gameview/styles.ts.
The hero wrapper stays overflow: visible. Clipping it, or any element in the preserve-3d chain, flattens the
chain: the artwork stops scrolling more slowly than the page, and is cut off at the wrapper's height instead of running
on behind the play bar and the cards. The sticky play bar controller sets overflow: visible on the wrapper on every
pinning update, which every pass runs, through the ledger.
Bounding the artwork (boundHeroOverflow)¶
The parallax layer is much shorter than what it holds: the image, then a canvas Steam flips vertically to continue the blurred background below it. The scroller counts that overflow towards its scrollable height, so with nothing else done the page scrolls on past its last card into empty space.
boundHeroOverflow (frontend/src/desktop/watcher/stickyPlayBarController.ts) closes that gap lower in the chain, on
the parallax layer itself. The layer is the end of the 3D chain and already transform-style: flat, so clipping it
flattens nothing above it:
overflow: clipon the layer, with anoverflow-clip-marginthat lets the artwork run on exactly as far as the page does: to the content's bottom, or to the scroller's own height on a page shorter than the window. How the margin is computed is the function's docstring.- A fade over the flipped canvas's last
HERO_FADE_PXbefore the clip edge, so the artwork ends in a gradient rather than on a straight line. It is a mask on the canvas itself, where the overflow is.
Both are recomputed on every pinning update, because the cards change height, and both go through the ledger.