Skip to content

build(desktop): add a dev build that adapts Steam's game page in the desktop client - #2389

Merged
danielcopper merged 4 commits into
danielcopper:mainfrom
cupajoe24:pr/desktop-scaffold
Oct 19, 2026
Merged

danielcopper merged 4 commits into
danielcopper:mainfrom
cupajoe24:pr/desktop-scaffold

Conversation

@cupajoe24

@cupajoe24 cupajoe24 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Adds a dev-only build that starts the desktop surface beside the panel, and the navigation watcher it starts: it finds the desktop client's window, follows its route to a RomM shortcut's game page, and adapts that page through a ledger that gives every change back. It draws no view of its own yet, and no shipped bundle behaves differently.

The desktop dev build

pnpm -C frontend build:desktop (frontend/rollup.desktop.config.js) builds the same three bundles as build:dev, and gives the two builds of the panel one more transform (insertDesktopSurfaceStart, frontend/scripts/start-desktop-surface.mjs) that rewrites src/index.tsx as it is bundled to call startDesktopSurface() right after disownStrandedPruneLeases();. The build fails rather than ship without the surface: when that statement is there other than once, and when a build of the panel never reaches src/index.tsx. Nothing else imports frontend/src/desktop/, so pnpm build, build:dev, CI and the release tarball carry none of it. Nothing in the build calls stopDesktopSurface(): a JS-context rebuild, not a teardown call, is what ends the panel.

The navigation watcher

desktop/navigationWatcher.ts looks for the desktop client's window (findDesktopWindow, desktop/desktopWindow.ts) when it starts, on a poll and on Steam's popup callbacks. On a RomM shortcut's game page it makes the play bar sticky, keeps the hero wrapper at overflow: visible and bounds the artwork's overflow on the parallax layer below it (boundHeroOverflow), hides Steam's duplicate sticky header, and pushes the right-hand controls to the right edge. Every change goes through watcher/restorationLedger.ts, and leaving the page gives back whatever the ledger holds, a pass that threw partway included; moving to another RomM game's page counts as leaving. Steam's elements are found without naming a hashed class, through the selector ladder in watcher/elementSelectors.ts, and no rung reads text or an ARIA label; Fiber (watcher/fiberInspector.ts) is only read.

A pass runs when something asks for one — an element added or removed, a change to the set of RomM shortcuts, a view's first render or failure — at most one an animation frame; no timer runs a pass on a page. The sticky play bar controller's ResizeObserver follows the sizes the clip margin is computed from. The watcher logs each transition, every failure it catches, and a RomM page it left without ever recognising it, with how long that page was open, on lines starting desktop:.

The watcher imports no view: it draws the gameView and playButton it is handed, and startDesktopSurface (desktop/index.ts) hands it neither yet, so Steam's own content and Play button stay. When a view is handed, Steam's part is hidden only once the view has rendered, and a mount that fails is not tried again for that appId and gives back any Steam part an earlier mount had hidden. Those paths are tested with stand-in components.

The watcher hears about new RomM shortcuts through onRomMAppIdsChanged, added to utils/rommAppIds.ts in its own commit. registerRomMAppId now returns early for an appId the set already holds; adding it again was already a no-op on the set, so this only keeps the listeners from being told of a change that did not happen. Nothing in a shipped bundle listens.

How it was checked

  • frontend/scripts/start-desktop-surface.test.mjs pins the rewrite, the modules it leaves alone, and the three ways the build fails.
  • navigationWatcher.test.ts drives a stand-in desktop window whose frames and observer run only when the test says, with real React roots. It covers adapting, re-adapting what Steam adds later, a switch between RomM games, the restore on leaving, a page handed one part or none, a view that never renders, the real registry, a pass that throws, mounts that fail, a page never recognised, and the window supervisor. Its "every change it makes is given back" case compares the style attribute of every element before and after; it fails when the ledger records a second original or when a style is written past it.
  • stickyPlayBarController.test.ts covers the pinning, the hero, the clip margin, and a first pinning that throws.
  • elementSelectors.moduleClasses.test.ts holds the first rung against made-up hashed class maps.
  • eslintNoClassHashes.test.ts keeps the class-hash check reporting, in strings, template parts and regex literals.
  • On a Steam Deck's desktop client (2026-10-14), a probe build credited every pass to its triggers: the watcher's interval and settle timeouts changed nothing the observer's passes had not, and the sticky play bar controller's settle timeouts caught only the parallax layer's clip margin, which the ResizeObserver now follows.
  • pnpm build leaves startDesktopSurface out of the standalone panel bundle, check:bundle and check:packages pass on it, and build:desktop's two panel bundles call it.
  • pnpm typecheck, pnpm lint, and the full Vitest suite pass (227 test files, 6018 tests).

Docs: docs/architecture/desktop-dom-architecture.md (new, How the desktop surface adapts Steam's game page), docs/architecture/frontend-bundles.md (The desktop dev build), docs/architecture/invariants.md, docs/contributing/frontend-dev-loop.md (Desktop surface dev loop), docs/contributing/development.md, GLOSSARY.md, frontend/src/desktop/README.md, .claude/rules/desktop-dom.md (new), CLAUDE.md.

Decisions

From #2388; D1 was replaced by D10, D3 by D6 and D6 in turn by D11, and D8 by D12.

  • D2. The navigation watcher imports no view. It draws what it is handed (DesktopGamePage, an optional gameView and playButton), and startDesktopSurface in desktop/index.ts is the one place that hands it anything. Today it hands it nothing, so the watcher adapts Steam's page and mounts nothing.
  • D4. The hero wrapper stays overflow: visible, so the artwork's 3D parallax is not flattened; the page's scroll gap is closed lower in the chain, on the parallax layer, by boundHeroOverflow.
  • D5. The watcher listens through onRomMAppIdsChanged (utils/rommAppIds.ts), which hears a synchronous run of changes as one call and a change that changes nothing not at all; registerRomMAppId returns early for an appId the set already holds.
  • D7 (2026-10-14). A pass runs only when something asks for one — an element added or removed, a change to the set of RomM shortcuts, a view's first render or failure, an attach to a window — coalesced to one an animation frame; no timer runs a pass on a page. A change that follows sizes is the sticky play bar controller's ResizeObserver's, which watches the play bar, the scroller, the content container and the parallax layer. Measured on a Steam Deck's desktop client, 2026-10-14: the watcher's interval and settle timeouts changed nothing the observer's passes had not, and the sticky play bar controller's settle timeouts caught only the parallax layer's clip margin.
  • D9 (2026-10-14). Desktop surface: Tender's game page in Steam's desktop client #2107 D5 lands with this PR: a test that leaving a page gives back every element's style attribute, and an ESLint check that no code under frontend/src/desktop/ names a minified class hash, each with its entry in the invariant register.
  • D10 (2026-10-14). The surface reaches a bundle only through pnpm -C frontend build:desktop, which rewrites src/index.tsx in the two builds of the panel to call startDesktopSurface() right after disownStrandedPruneLeases();, and fails when that statement is there other than once, or when a build of the panel never reaches src/index.tsx. pnpm build, build:dev, CI, mise run dev and the release tarball carry none of it. Nothing in the build calls a stop: a JS-context rebuild, not a teardown call, ends the panel.
  • D11 (2026-10-16). Every change the watcher makes to Steam's page goes through a DomRestorationLedger, restored whenever the ledger holds anything once the page is left — for a page that is no RomM shortcut's or for another RomM game's — and when the watcher detaches. Fiber is read and never written. A selector never names a hashed class: each finder in watcher/elementSelectors.ts resolves through a ladder of @decky/ui's resolved classes, the readable part of a class name, Fiber names, and structure; no rung reads text or an ARIA label, which read differently in every language — on a German desktop client only the first rung finds the play button and the play bar. The hero's parallax layer and mirrored canvas are found by their computed transform. The rules are .claude/rules/desktop-dom.md.
  • D12 (2026-10-16). Steam's own part of the page is hidden only once the Tender view replacing it has rendered. A mount that fails — as it is mounted, or later through onUncaughtError — is logged and not tried again for that appId, and has the next pass give Steam's page back before it adapts the page again. Every failure the watcher catches is logged with what it was doing.
  • D13 (2026-10-16). A RomM game page the finders never recognised is a warning, logged once when the page is left, naming the part not found and how long the page was open. A page left before Steam drew it is reported the same way: telling the two apart in the watcher would take a threshold — passes or time — that nothing has measured, and the time open tells them apart for whoever reads the log.

Closes #2388. Part of #2107

@github-actions github-actions Bot added area:ui Frontend UI, game detail page area:architecture Code structure, refactoring labels Oct 14, 2026
@cupajoe24
cupajoe24 force-pushed the pr/desktop-scaffold branch from 333b4e9 to 8f64cb3 Compare October 14, 2026 01:04

@danielcopper danielcopper left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a sound place to start: no shipped bundle carries any of it, the ledger, the read-only Fiber and the realm handling are the right shape, and #2107 D5 now settles how the two cross-file rules get checked. Most of the comments below come down to the failure paths: a pass that throws, a mount that fails or an attach that throws leaves Steam's page changed or the watcher's timers running, and says nothing. They are latent today because nothing is handed in, and live with the first view, so I would fix them here. Measured on a German desktop client along the way: @decky/ui's class maps are the only rung that finds the play bar there.

Comment thread frontend/src/desktop/watcher/elementSelectors.ts Outdated
Comment thread frontend/src/desktop/watcher/restorationLedger.ts
Comment thread frontend/src/desktop/navigationWatcher.ts Outdated
Comment thread frontend/src/desktop/navigationWatcher.ts Outdated
Comment thread frontend/src/desktop/index.ts Outdated
Comment thread frontend/src/desktop/navigationWatcher.ts Outdated
Comment thread frontend/src/desktop/navigationWatcher.ts Outdated
Comment thread frontend/src/desktop/watcher/restorationLedger.ts Outdated
Comment thread frontend/src/desktop/watcher/fiberInspector.ts
Comment thread frontend/src/desktop/navigationWatcher.ts Outdated
@cupajoe24
cupajoe24 force-pushed the pr/desktop-scaffold branch from 8f64cb3 to ee677ab Compare October 15, 2026 03:15
@cupajoe24

Copy link
Copy Markdown
Contributor Author

Thank you, and for the German measurement in particular: it is what decided the ladder. Every failure path you found was real and is fixed, and the tests for the fixes fail without them, including under the two mutations you named for the ledger. Setting the window only after the attach returns is the exception: since a window that failed is never tried again, undoing it changes nothing a test can see. Three catches that only log have no test: detachCurrent's, the ResizeObserver's and the popup registration's. Your points became five decisions on #2388:

  • D6 (replaces D3): no text or ARIA rung. The play button's rung goes, and so does the badge finder's "LAST PLAYED" / "PLAYTIME" match, which had the same English-only problem.
  • D7: a pass runs only when something asks for one, and no timer runs one. The interval and both sets of settle timeouts are gone; the numbers are in the reply on the 250 ms interval.
  • D8: Steam's own part is hidden only once Tender's view has rendered, and a mount that fails is not tried again for that appId.
  • D9: Desktop surface: Tender's game page in Steam's desktop client #2107 D5 lands here: the style-attribute test and the class-hash check, each with its register entry.
  • D10 (replaces D1): the build also fails when the anchor is there more than once, or when a build of the panel never reaches src/index.tsx.

The architecture page is rewritten along your page-wide notes, and the realm point now lives in the widened CLAUDE.md trap. Details inline.

@danielcopper danielcopper left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of round one is done and holds: 19 of the 20 threads are addressed, the two ledger mutations from round one now fail the snapshot test, the class-hash check fails on a string and on a JSX className, and the suite, typecheck and lint pass at this head. What is left is mostly the same kind as before, failure paths that go live with the first view: a switch between two RomM games and a controller whose first pinning throws, both inline with probes. One more from thread 7: a RomM page the finders cannot recognise still logs nothing, and after a Steam update that is the line one would look for.

Comment thread frontend/src/desktop/navigationWatcher.ts
Comment thread frontend/src/desktop/watcher/stickyPlayBarController.ts Outdated
Comment thread frontend/src/desktop/navigationWatcher.ts
Comment thread CLAUDE.md Outdated
Comment thread CLAUDE.md
Comment thread CLAUDE.md
@cupajoe24
cupajoe24 force-pushed the pr/desktop-scaffold branch from ee677ab to 256f923 Compare October 15, 2026 17:00
@cupajoe24

Copy link
Copy Markdown
Contributor Author

Thank you. All seven points held. Each fix comes with a test that fails without it: a switch between two RomM games; a synchronous mount failure, which now shares one handler with onUncaughtError; the observer when the first pinning throws; and regex literals in the hash check. D8's rendered check and onUncaughtError's pass, which had no test, now each fail one when removed, and the snapshot test fires the triggers its entries name. The realm trap and the register entries are corrected as you suggested. For thread 7: a RomM page the finders never recognised is now a warning, logged once when the page is left, naming whether the overview panel or the play section was not found. A pass that misses the page does not log it, because a pass that runs before Steam has drawn the page misses it too, and that miss is no failure. "What it logs" on the page says so. The tests have one case for each missing part, and one where the page is recognised once Steam has drawn it, which logs nothing.

@cupajoe24
cupajoe24 force-pushed the pr/desktop-scaffold branch from 256f923 to bdde0d0 Compare October 15, 2026 17:38

@danielcopper danielcopper left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All seven points hold, and each fix is pinned: with the switch-as-leave rule, the dispose on a throwing first pinning, D8's rendered check, the failure handler's schedulePass() or the regex-literal selector taken out, a test goes red each time, and both probes from the last round now pass. One small thing inline. And D6 and D8 on #2388 now describe less than the code does: a switch to another RomM game also gives the page back, and every failed mount, the synchronous one included, gives Steam's part back; a dated revision of each would keep the issue and the body in step.

Comment thread frontend/src/desktop/navigationWatcher.ts Outdated
cupajoe24 added 2 commits October 16, 2026 12:33
onRomMAppIdsChanged(listener) calls the listener once after a synchronous
run of changes to the set, on the microtask after it, and not at all for a
register or unregister that changes nothing. registerRomMAppId now returns
early for an appId the set already holds; adding it again was already a
no-op on the set, so the early return only keeps it from notifying.

Nothing in a shipped bundle listens yet.
…desktop client

pnpm -C frontend build:desktop builds the three usual bundles, with
src/index.tsx rewritten in the two builds of the panel
(scripts/start-desktop-surface.mjs) to call startDesktopSurface() after
disownStrandedPruneLeases();. The build fails when that statement is there
other than once, or when a build of the panel never reaches src/index.tsx.
No other build carries frontend/src/desktop/.

startDesktopSurface() starts the navigation watcher, which finds the
desktop client window, follows its route to a RomM shortcut's game page and
adapts the page — sticky play bar, the hero's overflow, Steam's duplicate
sticky header, the right-hand controls — through a restoration ledger that
gives every change back when the page leaves it, a pass that threw included.
It runs at most one pass an animation frame and on no timer of its own,
logs what it found and every failure, and hides a part of Steam's page
only once the view replacing it has rendered. It is handed no view to
mount, so it draws nothing of its own.

ESLint fails a string under frontend/src/desktop/ shaped like a minified
class hash, and a test holds that leaving a page gives back the style
attribute of every element on it (danielcopper#2107 D5).

docs/architecture/desktop-dom-architecture.md describes the watcher, and
.claude/rules/desktop-dom.md holds the rules for changing Steam's page.
@cupajoe24
cupajoe24 force-pushed the pr/desktop-scaffold branch from bdde0d0 to e859684 Compare October 16, 2026 16:46
@cupajoe24

Copy link
Copy Markdown
Contributor Author

Thank you. D6 and D8 on #2388 are struck with dated revisions. D11 restores the page on leaving it for another RomM game's page as well, and D12 has every failed mount give Steam's page back, one that throws as it is mounted included. The body carries D11 to D13 in their place. The inline point is answered there.

@danielcopper danielcopper left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All points from the last round hold. Logging how long an unrecognised page was open tells a page left early apart from one the finders cannot read, without a threshold nobody has measured; checked by measuring from the last missed pass instead of the first, which fails both new cases. On the desktop client the watcher adapted a RomM game page and found the panel, the play bar and the Play button by module class.

@sonarqubecloud

Copy link
Copy Markdown

@danielcopper
danielcopper merged commit 11c920d into danielcopper:main Oct 19, 2026
17 checks passed
@cupajoe24
cupajoe24 deleted the pr/desktop-scaffold branch October 19, 2026 18:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:architecture Code structure, refactoring area:ui Frontend UI, game detail page

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Adapt Steam's game page in the desktop client from a dev-only build

2 participants