Skip to content

QAM panel

The Quick Access Menu panel is the plugin's own surface inside Steam's QAM, behind Tender's own entry in the tab strip — beside Decky Loader's where Decky is running, and on its own where it is not. It opens on Main and reaches every other page from there. Steam renders the QAM 348 px wide; a page of this plugin can widen it to 854 px — the width Steam's own Friends tab uses — for as long as that page is mounted. This page owns the panel's structure: which pages exist, which are wide, how a page is navigated and laid out, and where each action has its home. The game detail page is a Steam route, not part of the panel, and is out of scope here; the state it shares across its surfaces is the Game-detail store (CONTEXT.md).

The structure below is the target decided in #1809 and rebuilt one page at a time under #1808. Where today's panel differs, the difference is stated; the PR that lands a page updates its row in the page table. The vocabulary — QAM page, Main, wide page, list and detail, notice, home — is defined in CONTEXT.md and used here without restating it. The width mechanism's decision record is ADR-0029.

Where the code lives

Module Responsibility
frontend/src/qam/ The entry itself: the patch that puts it in the strip, the tab glyph, and the boundary the panel renders inside
frontend/src/index.tsx (QAMPanel) The router: one Page value, one mounted page, a module-level currentPage that survives a QAM remount
frontend/src/types/navigation.ts The Page union — every page the router can land on
frontend/src/bigpicture/MainPage.tsx Main
frontend/src/bigpicture/SyncPage.tsx, frontend/src/bigpicture/sync/ Sync — the frame and its three left-column bodies, plus useSyncPage (its reads and actions) and the numeric-column split both its tables take
frontend/src/bigpicture/LibraryPage.tsx, frontend/src/bigpicture/library/ Library — the frame, and in library/ both tabs and their state (its own row, further down)
frontend/src/bigpicture/SettingsPage.tsx, frontend/src/bigpicture/settings/ Settings and its sections
frontend/src/bigpicture/DataManagementPage.tsx, data/, RemovedGamesCleanup.tsx Data Management — the inventory rows, their panes and the page's state
frontend/src/bigpicture/DownloadQueue.tsx Downloads
frontend/src/bigpicture/library/ The Library tabs: usePlatformsPage, PlatformsTab, PlatformDetail; useCollectionsPage, collectionKinds, CollectionsTab, CollectionsDetail
frontend/src/utils/deckyUiInternals.ts Honest typing for @decky/ui values that come from a webpack probe: the frame's class names, Tabs, ScrollPanel, the controller glyph
frontend/src/utils/qamExpansion.ts The panel's width: the expand and hide messages, the injected max-width rule, and the four paths that clear both
frontend/src/bigpicture/layout/ The wide-page frame: WidePage (the Back/title line, tabs, measured height, entry focus), ScrollRegion, Columns, ListDetail, pane
frontend/src/utils/entryFocus.ts Which stop a body opens on, a page's declaration when that stop is not it, the rule for a body that swaps under the reader, and the .focus() + gpfocus pair
frontend/src/utils/syncRunView.ts useSyncRunView — the run in flight as a page renders it: stage label, coarse bar, position within the running unit, fine-detail line, estimate, and the run's end
frontend/src/utils/runUnitsStore.ts The run's work queue, one row per unit: the plan's riders, how far the run has got, and what each unit's apply produced
frontend/src/utils/previewState.ts What a page asks of a pending preview: has it anything to apply, and how long is it still accepted (the half Main reads)
frontend/src/utils/syncResume.ts Whether the next sync continues a run or starts one over, and what that puts on the Sync page's start button — the name the session-budget card quotes
frontend/src/utils/syncProgress.ts The frame every page reads a run from, and the one rule it enforces on its writers: a run that has ended stays ended
frontend/src/utils/ module stores State that must outlive a page: sync progress, pending preview, downloads, prune, the game-detail caches

The entry

Steam ships no API for adding a Quick Access tab, so frontend/src/qam/quickAccessEntry.tsx patches the two renderers that draw the menu — the browser view Gaming Mode uses and the embedded one — and pushes an entry into the tab array they hand back. Decky Loader patches the same two renderers, and the two compose: afterPatch chains handlers rather than replacing them.

The entry's heading is an element carrying Steam's own heading class (quickAccessMenuClasses.Title), with the plugin's name inside it, rather than the name as a string. That is a measurement and not a preference: in the running Quick Access document, Steam's own tabs head their panels with an element carrying that class, drawn at 22 px / weight 700, while a bare string lands as a plain text node in the panel container at body size, 16 px / weight 400 — which is what the entry shipped with. Without the class map the start-up check has already refused the panel, so the heading stands over the start-up failure page, and it is drawn unstyled rather than not at all. The panel's content carries no heading of its own: Steam draws the tab's, and a second would be two.

Nothing in this path touches Decky. Not window.__TABS_HOOK_INSTANCE, whose deinit() Decky's own constructor calls on whatever it finds there; and not its add() either — Decky's render counts its decky-marked entries against its own list length, and a foreign entry desynchronises that guard into re-pushing every tab with no convergence. The entry carries Tender's own marker and Tender's own key (tender), so it is invisible to that count. None of this is a presence check: the choice of bundle already asked whether Decky Loader is serving, from the machine rather than from the window (loading-the-panel.md), and the frontend does not re-ask.

Where the entry sits is not ours to set once. afterPatch runs the previous handler first, so whoever patches last pushes last and lands lowest; measured both ways in the spike behind #1897 — patched before Decky booted, Tender came out above it, and re-patched beside a running Decky, below. Install order is a property of which program starts first, so the placement is re-asserted on every pass instead: the handler moves the entry to the end of the array each time it runs. Moving an entry is invisible to Decky's guard, which counts marked entries and never asks where they sit.

Every Quick Access remount builds a new tab array — Gaming Mode to Desktop and back, or Big Picture opening in a window, replaces the menu's browser view and with it the React tree, the array and the document. Three consequences, and each is a way to get this wrong:

  • No array is held. The spike kept every array it had pushed into so that it could take its entries back out; that set only grows, one dead array per remount for the life of the process. There is nothing to take back out here — the injector refuses to load the panel into a context that already carries its marker, and what clears that marker is a JS context rebuild, which takes this module and its patches with it — so the module holds no array and every dead one is collectable with its view.
  • The entry is added again to whatever array the pass is handed, and the entry's own marker is what keeps a second pass over an array it is already in from adding a second one.
  • Anything bound to the menu's own window is bound from inside the menu's React tree, so the remount re-binds it. The entry itself binds nothing there — the glyph is static and reads no state at all — but a page the panel mounts does: utils/qamExpansion.ts's stylesheet and MutationObserver, utils/entryFocus.ts's focus listeners, bigpicture/layout/WidePage.tsx's ResizeObserver, and bigpicture/layout/ScrollRegion.tsx, which reads the view per event and retains nothing. Each of the four sits inside an effect or an event handler of a component the menu mounts, which is what makes the remount re-bind it; one held at module scope would work until the first Gaming-Mode-to-Desktop switch and then do nothing, silently.

An already-mounted menu is adopted rather than waited for. React flattens the renderer's memo wrapper at mount and carries the resolved type on the fiber, so swapping the module's export afterwards reaches nothing already on screen. That is React's own behaviour rather than @decky/ui's, which supplies the patcher and not the flattening, and #1897 confirmed the consequence on the device — the patched renderer's own handler was never entered. Without the adoption the entry would arrive at the menu's next remount, which follows from what a remount does rather than from an observation. Decky adopts for the same reason.

The glyph

The mark reduced for the strip: no disc, one tone, the sync ring levelled and thickened, and the body as the button bars drawn as solid capsules. The strip carries nothing but single-tone free-standing glyphs — read off a screenshot rather than measured on the device — a bell, friends, a cog, a bolt, a note, a question mark, and Decky's plug — every one of them a bare silhouette, so a disc behind ours would be the only backing plate in the row.

Nothing marks the four button positions, and that is arithmetic rather than taste. They shipped punched out of the bars as holes, on the reasoning that took the disc away: with one tone a filled dot has nothing to be filled with that the bar is not already, so it disappears into the bar it sits on. At strip size the bar cannot pay for the cut. The glyph asks for 28 px across a 200-unit square, so one unit is 0.14 px: a bar 31.38 units wide is 4.39 px, a hole at radius 12 is 3.36 px across, and what is left of the bar either side of one is 0.52 px. Half a pixel cannot draw a bar, so the holes ate the body instead of marking the buttons, and the bars are solid.

The arc and its arrowhead are thickened by 1.3, which is the same size read the other way: at the 28 px the glyph asks for, the mark's own 15.5 stroke lands at 2.17 px, the thinnest thing in a row of solid silhouettes. The mark's own weight reads too fine there, and the 1.3 is what the arc carries instead.

It is generated, by scripts/logo/tabicon.py through build.py --tab-icon, into frontend/src/qam/tabIconArt.ts; the geometry comes from the mark's own drawing routines, so the two cannot drift. Its two departures from the mark's geometry, and why, are at tabicon.STRIP_GEOMETRY.

Nothing about it moves, and that is a measurement rather than a taste. It shipped with a ring that turned while a sync ran and a body that folded while the entry was active. Read over CDP on the QuickAccess target in 6-second windows, with only the fold running:

fold running animation off
TaskDuration 1.726 s 0.0077 s
LayoutCount 720 1
RecalcStyleCount 720 1
LayoutDuration 0.7626 s 0.001 s

That is roughly 29% of one core for as long as the menu is open, and a full layout plus style recalc 120 times a second — twice a frame at 60 Hz — because animating a path's d forces layout every frame. The ring was not running in either reading, so nothing here is a measurement of it: starting a sync run was not possible in that session. It was an <animateTransform type="rotate"> on a <g> rather than an animation of d, so the mechanism above does not reach it and what it would have added is simply unknown. On a handheld, that is not a trade a decoration rendering at 24 px gets to make. TabIcon.test.tsx fails if any of SMIL's animation elements comes back — it can see nothing else, and motion driven from CSS or a rAF loop would pass it — because the cost is invisible to every other check here.

Two things about it are unmeasured, and neither is guessed at:

  • Whether it lands at the 28 px it asks for. It asks in em rather than in pixels so it scales with Steam's UI, and the 1.633em it asks with is scaled off a measurement rather than arithmetic: the 1.4em it used to carry drew a box of 24 px, which is 17.14 px to the em. That is not the 16 px parent font-size read beside it, so plain CSS em resolution cannot be the whole story, and what sits between the two was never established — nor was it established that the strip takes the length it is handed rather than clamping it. The 1.633 needs only the ratio and holds either way.
  • Whether it takes the colour of the selected tab. It asks for currentColor; what that inherits in the strip is not established here.

The boundary

The panel renders inside PanelErrorBoundary, which has exactly one action. Without a boundary a throw inside the panel unmounts the tree it was rendered in — Steam's Quick Access view, not ours — so the fault would cost the reader every tab in the strip rather than one. @decky/ui's own ErrorBoundary is not a component it defines: the module is one findModuleExport sweep for a class of Steam's, so both bundles would resolve the same one. This boundary is Tender's because a search into Steam's bundle can stop matching on a client update, and a boundary is the one component whose absence is discovered by the fault it was there to catch.

Reload clears the caught error, and that is the whole of the rebuild: React has already unmounted the subtree by the time the button exists, so rendering it again mounts a new tree with no state carried over. It deliberately does not re-evaluate the bundle (the injector's job, and nothing in this tree can ask for it) nor re-run the plugin factory, which would install a second copy of every listener and patch it registers. What survives is the panel's module-level state — the open page, the sync progress, the caches — so a reader whose Settings page threw comes back to Settings.

Two widths

Every page is either narrow (348 px, 300 px of content) or wide (854 px, 806 px of content). The width belongs to the page: a page is wide or it is not, and no view inside a page changes it. Main and Downloads are narrow; Sync, Library, Settings and Data Management are wide.

On the Deck, wide means full screen. The Big Picture viewport is 854 × 534 CSS px at devicePixelRatio 1.5 (1280 × 800 physical), so the narrow panel covers 41 % of the width and the wide one covers all of it — there is no library left beside a wide page. A desktop Big Picture window is wider and shows one, which is why width judgements are made under mise run dev:ui-scale deck and nowhere else.

That 534 is the internal display and nothing more — do not size against it. The dev loop's windowed Big Picture is a different viewport on the same machine: measured through CEF during the third device round, the QAM view reported innerHeight 764 at devicePixelRatio 1.71 on a 1496 × 842 screen, where the width still came out at 855. Both numbers are real measurements of different configurations, and code that assumes either is wrong on the other — which is why the frame measures the space it is actually given rather than deriving it from a recorded viewport.

How a page gets wide, measured on the device rather than read from documentation:

  • Steam's main window holds the QAM in a sliding container: an absolutely positioned element as wide as the viewport and 854 × 454 CSS px on the Deck — the 80 px above it is Steam's top bar — anchored right and pushed off-screen by transform: translateX(506px), so 348 px stay visible. Steam's Expanded class sets translateX(0). Its class names are hashed and there is no ViewPlaceholder to match on any more, so the container is found by geometry: absolutely positioned, a transform set, at least 800 × 400. The class follows one MobX observable on the FriendsUI store, which listens for message events on the SharedJSContext window — the window plugin code runs in. A wide page posts { message: "QamFriendsExpanded" } to window on mount and { message: "QamFriendsHidden" } when it lets go. The target origin is always window.origin, which addresses the message to that window and always matches it. A well-formed target origin that does not match is checked at delivery and the message is discarded in silence, so a literal one would leave the panel simply never widening.
  • Every tab's content panel carries max-width: 300px; only Steam's Friends panel lifts it. A wide page injects one stylesheet whose :has() rule lifts the cap for a marker class on the plugin's own subtree. Class names come from quickAccessMenuClasses, which can be undefined; [id^="quickaccess_content_"] is the fallback selector. Steam builds that id from the key of whichever entry rendered the page, so the prefix is matched and never the whole id — what a string key produces has not been measured. TabGroupPanel sits on that same element, measured under Decky's numeric key, which is why walking the DOM by id and writing the CSS against the class reach the same panel. That same sheet carries one rule that is not about width — Steam's own outline: outset #fff 2px for a disabled button under .gpfocus, which Steam's stylesheet omits. A wide page keeps its buttons rendered-and-disabled rather than hidden, so the stick lands on them and the focus ring would otherwise disappear for that row; it rides in this sheet because the sheet is already scoped to the wide root and a second injector for one selector would be a second thing to clear.
  • Result: the visible panel goes from 348 px to 854 px and the tab panel from 300 px to 806 px (854 minus the 48 px tab rail). The QAM browser view itself is 854 px wide in both states, so only the sliding container's geometry, read through findSP(), proves an expansion.

The flag is Steam's and global, so the page that set it clears it: on unmount (navigation away, plugin closed), when the tab the page sits in stops being the active QAM tab (the ActiveTab class on the panel's parent — a tab switch is a class change, not an unmount), and when the QAM closes (useQuickAccessVisible). It no longer clears from onDismount: that was Decky's teardown hook and nothing calls it now. Which tab that is is never asked: the page walks up to the panel around it and reads the class off that panel's parent, so the same code answers for Tender's entry and for Decky's.

Both of those questions proceed when they cannot be answered, and each one costs at worst an expansion the other paths still clear — the alternative default leaves a wide page permanently narrow with nothing saying why. The tab question cannot be asked when no panel is found around the page, or when the class probe came back undefined. The visibility question cannot be asked when Steam's focus controller holds no navigation trees, which is the desktop client until Big Picture has been opened: the menu's window is reached through the QuickAccess-NA tree, so with no trees there is nothing to read a document's visibility off. It is asked through utils/quickAccessVisible.ts, Tender's own copy of @decky/ui's hook, because upstream's throws on that reading instead of answering — why a copy rather than a guard at the call site is written out at that module, along with what else it changes besides the guard.

Steam moves the same flag on its own, in both directions, and neither is a bug in the plugin. OpenQuickAccessMenu clears it (SetQAMFriendsChatExpanded(false)) on every QAM tab change away from Friends, which is a second net under the plugin's own ActiveTab observer; and the Friends tab's list expands it from onFocusWithin, so Friends goes wide the moment gamepad focus enters it. A Friends panel that widens after a wide page closed is Steam doing that. Both are in chunk~2dcc5aaf7.js in Steam's own bundle, where the receiver is also visible: OnMessage on the FriendsUI store sets m_bQamFriendsExpanded from exactly the two messages the plugin sends, and Steam's own senders post with the literal "https://steamloopback.host" — which is what window.origin is in the SharedJSContext.

Steam's tabbed page fills its parent instead of growing, and nothing in the QAM chain provides a height. A wide page therefore measures the space left below its header and takes that as its height; its regions scroll inside it. A min-height is not enough — it clips. What is left after whatever chrome sits above the page and the frame's Back-and-title row is the panel's clientHeight less the body's offset within it, so it follows the view rather than any recorded number: measured through CEF on the dev window's 764 px view, with the change below applied to the running panel, that is a body of 660 px ending flush with the panel's box. A tabbed page spends 58 px of it on Steam's tab row, which is drawn over the top of the content pane rather than above it.

That measurement has to be free of the scrolling panel's own offset, and only a layout-relative one is: the body's position inside the scroller's content — its viewport top minus the scroller's, plus the scroller's scrollTop — subtracted from the scroller's clientHeight. Every viewport-relative form fails, because the panel's own scrollTop moves the body's rect and leaves the panel's own rect where it is. That made the measurement feed itself, and the loop has no fixed point: a body measured part-way down comes out that much too tall, the panel then has that much more to scroll, and nothing re-measures. It reached a device as a page that scrolled as one piece, tab row and all — 1245 px of body inside a 750 px panel.

Measured live in the QAM over the mounted page, at panel offsets 0 / 200 / 500 / 634 px, window.innerHeight - top answers 648 / 848 / 1148 / 1283 — and so does scroller.getBoundingClientRect().bottom - top, because the panel's rect bottom is 764.3 against an innerHeight of 764. Bounding to the panel instead of the window is therefore not the fix; it changes no number at any offset. The layout-relative form answers 648 at all four. Those four come from an earlier round, before the Back row and the title shared a line, so the body sat at offset 102 where it now sits at 89.8 — which is why they are 12 short of the 660 above rather than the height of the gap this frame no longer keeps. What the passage is about survives the difference: the same form answers the same number at every offset. What makes a non-zero offset reachable at all is that QAMPanel resets the panel's scroll inside a requestAnimationFrame, a frame after the page's own layout effect has already measured.

The height alone is not the whole fit, because the frame's own ancestors can hang below it. The chain this was written against was Decky Loader's: it wrapped a plugin's content in a box that sat 34 px below the panel top — its plugin title — and took height: 100% of a parent it was already inset within, so its bottom landed 50 px past that parent's. Behind Tender's own Quick Access entry there is no such wrapper, and zero is a reading the routine below was written to survive rather than a case it had to be taught. Nothing of ours is painted in those 50 px, but the panel scrolls by them, and a scroll of that size takes the frame's Back row off the top. WidePage measures the overhang (ancestorOverhang, summed over each ancestor up to the scroller) and cancels it with a negative bottom margin on the page root rather than taking it out of the height: a margin changes what the box claims after itself, not where it paints, so the ancestors end where the scroller's box does and nothing on the page moves. The height and that pull-up are one measured value applied in one render, because each half alone is measurably useless: applied live to the running panel, the height without the margin overflows the scroller (scrollHeight 800 against a clientHeight of 750 — 50 px of scroll, which is what takes the Back row off the top), and the margin without the height moves nothing a reader sees, the page still ending on the same line with the same band under it.

What makes the pull-up cancel anything is a structural assumption, and it is worth stating on its own, because the overhang is measured against a PARENT and the margin is applied to our root. The boxes between our root and the scroller are content-sized: the wrapper's bottom is our body's plus its own inset, at every body height. So pulling our root's margin box up carries those bottoms up with it, and the chain stops claiming exactly the overhang the margin names. The assumption is a SHAPE rather than a value — every number is re-measured, so a Decky release that merely overhangs by a different amount is already handled — and it holds because the overhang is the wrapper's own inset and padding rather than anything derived from what we put inside. It was checked at several body heights in two panel geometries.

If it ever stops holding, this is what it looks like. A wrapper pinned to a height of its own — a future Decky or Steam nesting the plugin differently — would not follow our body up: growing the body would overflow the wrapper instead of the wrapper's parent, the margin would cancel nothing that was in the way, and the panel would scroll again, which the reader meets as the Back row leaving the top. The check is one reading: the lowest ancestor bottom in the chain should equal the body's own with the margin applied, and sit an overhang below it without.

Buying the room instead of cancelling it is what the first cut did, and it cost the bottom of every wide page: the body gave up 50 px of its own so the wrapper's empty 50 would fit, which left the wrapper ending 12 px short of the panel's box — the gap the frame kept — and our content a further 50 px above that — an empty band across Settings, Library and Sync, with content that would have fitted clipped out of the difference. Measured live — on Settings when it was reported, and again on Library, which answers the same because the scroller is the panel's rather than the page's — the scroller's box ran to y=764.3 (clientHeight 750, unscrollable) while the page root ended at y=702.

Two further gaps stood under a wide page after that, and neither was earned. The first was ours: the frame kept 12 px of breathing room off its own measurement, which is why a page ended at y=752 inside a panel whose box runs to 764.3. A QAM panel of Steam's own, measured in the same document, runs its content to its box with a gap of 0, so the constant went rather than being set to zero — room under a page belongs to that page's layout, where it can be seen and adjusted, not to a number the frame takes off every page's height. The second is Steam's, and it is cancelled by an override rather than absorbed: a tabbed page's content scroller carries padding-bottom: 40px (rule ._1X4dtbZ_AMX_DXT-SGiK01), and that scroller sits inside the box WidePage measured and handed the tab as its height — so on Library the content stopped at y=712 inside a body of ours that ran to 752, a 40 px reserve taken out of a height the frame had already paid for. An untabbed page renders no such scroller and keeps none of it, which is why Library read as having a deeper band than Settings and Sync — the difference that was reported and could not be explained. The injected sheet zeroes the padding for a wide page of ours (qamExpansion.ts, written against the readable _TabContentsScroll rather than the hashed class, and degrading to Steam's padding if either name goes). With both gaps gone, Library's body measured 660 px and its content ran to y=764.0 against a panel box of 764.3, scrollHeight still equal to clientHeight. What the reader gets is 52 px on the tab's own scrolling region — the box the rows live in — measured on Library at 161.8 → 712 before and 161.8 → 764 after, a clientHeight of 550 against 602 over the same 1570 px of content.

No test here can see any of that. happy-dom performs no layout, so WidePage.test.tsx pins the arithmetic — the height, and that the pull-up equals whatever overhang was measured — and qamExpansion.test.tsx pins that the override is in the sheet and scoped to our root. That the panel does not scroll, and that the band is gone, is a device observation each time.

A region scrolls the way the rest of the QAM scrolls: by moving focus. Every scrolling region goes through ScrollRegion, which renders Steam's plain ScrollPanel — the container the QAM's own tab panel is built from, and the one Steam's tabbed page wraps each tab's content in. It is an overflow-y: auto box that takes no focus of its own, so the rows inside it take focus directly and Steam scrolls the focused row into view.

Every row a reader must be able to reach is a focusable row. A toggle, a button, or — where a table row carries no action of its own, so the reader can still walk the table — a Focusable with an onActivate handler. The handler is what makes an action-less ROW a stop, and a region of nothing but text declares focusableIfEmpty instead — the cleanup modal's details region is the one that does; a bare Focusable is a container that passes focus on to its children rather than taking it. Plain text that only accompanies a row, a hint under a group, scrolls with its neighbours and need not be reachable itself. This is what focus-driven scrolling costs: content nobody can focus cannot be scrolled to.

The repository ESLint rule tender/qam-focusable-row checks one syntactic slice of that rule in the QAM modules listed above. A Focusable imported from @decky/ui must declare a stop of its own, contain a static focus stop, or contain a child/spread whose focusability cannot be established statically.

What declares a stop is what Steam's nav node reads. The base panel forwards focusable and focusableIfEmpty into that node's options, and promotes a node carrying onActivate or onOKButton to focusable — but only where neither option was supplied; onCancelButton it wires up while promoting nothing. The node's own answer is GetFocusable(), which reads four options — focusable, focusableIfEmpty, childFocusDisabled, fnCanTakeFocus — and answers "self" for a truthy focusable whatever the node holds, and for focusableIfEmpty only while childFocusDisabled is set or the node holds no child nav node at all. Child nav nodes are what other Focusables and Steam's own nav components mount, focusable or not, so a node holding a non-focusable one answers "children" and is no stop even with focusableIfEmpty. That is what makes it the right declaration for a region of nothing but text — it steps aside by itself the day a control is added inside — and what the cleanup modal's details region uses. FocusableProps declares neither option, so both arrive through a spread or a cast. The panel's other text-only stops — SessionBudgetBanner and its peers — carry a no-op activate handler instead, which is the cheaper spelling and keeps the stop even once a control lands inside them.

The matcher asks two questions, and only one of them of a plain div. Those props, of the row and of a Focusable among its descendants, since a host element renders no nav node and they are inert on one. And DOM focusability, of a DESCENDANT only: a tabIndex, a button, input, select or textarea, an a with an href. So a row whose own tabIndex is its only affordance is reported — that attribute reaches the rendered element rather than the nav node, and no D-pad move from a neighbour reaches it — while the same attribute on something inside a row leaves the row alone, because a container holding what the browser can focus is not one this rule can call empty. An explicit {false} declares nothing; every other value is taken at its word.

That attribute is Steam's to write, and this is its one statement here. The base panel writes it wherever a nav option is set — including where onActivate or onOKButton promoted the node to focusable — and never where the tree navigates virtually, so a container that declares nothing carries none. Everything below that reads div[tabindex="0"] as a focus stop is reading that symptom rather than what caused it, and says so by pointing here instead of restating the condition.

This catches an action-less row written as a static wrapper while leaving structural containers and opaque children alone. What it cannot read, it passes: one opaque child anywhere among a row's children — a &&, a call, a bare identifier — passes the whole row, which is how the cleanup modal's details region stood unreachable with the gate green, and why a row's reachability is a review question and not a gate one. It does not check focus order, runtime reachability, edge revelation, scrolling geometry, or controller behaviour; those parts remain a device and review invariant.

The one place a page cannot buy its way out of that is the content OUTSIDE its focusable rows, and the frame handles it rather than each page: a heading, a counts line or a column header sitting over the topmost row, and a legend, a total or a hint under the last one, are not focusable and have no neighbour to ride along with, so once the reader has scrolled past them Steam has no reason to bring them back — it scrolls only far enough to show the focused element. ScrollRegion therefore scrolls itself to the top when focus reaches the first stop in it, and to its end when focus reaches the last. Every region built with ScrollRegion gets that, which is not the same as every region on every wide page: a tabbed page's own tab content sits in Steam's ScrollingTab, so a tab that does not build its own regions is not covered — none does today, since both of Library's tabs are list and detail. Two properties make it safe rather than a fight with Steam's own scrolling. The triggers are "nothing focusable is above me" and "nothing focusable is below me", never "I am the first match" or "the last" — a container Focusable that declares a stop, or is promoted to one by an activate handler, renders tabindex="0" of its own and precedes in document order every row it wraps, so it is never the last match and a wrapped row is never the first, and an equality test against either end would silently never fire wherever a page wraps its rows, which ListDetail does for every row. So the first rule discounts the focused element's own ancestors and the second its own descendants. And each acts only where the focused element still fits in the region at the offset it would move to: where the content beyond it is taller than the region there is no offset showing both, Steam would scroll the element straight back, so nothing is done at all. A stop at both ends at once reveals the top where the top fits, and otherwise the end where that fits.

The set of shapes it counts as a focus stop is measured in the running QAM, not assumed: Steam's own components render div[tabindex="0"] — the symptom Steam writes for a declared stop, never what makes one (above) — and a DialogButton is a native button carrying no tabindex attribute at all.

A region also keeps the wheel to itself. Its overscroll-behavior is contain, because all three nested scrollers here — the region, Steam's ScrollingTab above it, the QAM panel above that — compute auto by default, so a mouse that reached the end of one went on to scroll the panel and took the frame's Back row off the top with it. A controller never showed it: Steam scrolls a region by moving focus, not by wheel events. The property names no axis of overflow, so it cannot undo the sideways clipping the bounds deliberately leave to Steam.

A list-and-detail page's detail region is keyed on the selection, so choosing another entry mounts a fresh region and its detail opens at its own top rather than at the offset the previous one was left at. A key rather than a ref, because the panel is reached through a webpack probe and nothing establishes that it forwards one; and it is safe because focus is in the list when the selection changes — that is what changed it — so nothing focused is unmounted.

ScrollPanelGroup — the sibling that binds gamepad direction to scrolling, and would carry unfocusable content — was tried on the device and rejected. It is focusable and its OK button focuses its first visible child, so each region became a focus stop of its own: the whole list outlined as one block, A to step into it, and only then rows taking focus. Steam's own QAM does not behave that way.

Both of a list-and-detail page's regions are ScrollRegions, and so is the frame's body when the page has no tabs and does not say it owns its regions. A tabbed body gets none from the frame, and neither does one whose page passes ownRegions: see "Building blocks → Tabs" for whose job it is instead.

Pages

Page Width Holds Today
Main 348 notices, status, the conditional slot, the download summary, the menu as described
Sync 854 preview as a table, the run as a plan, Skip preview, Force Full Sync, Steam memory, session budget, last runs as described; the import choice (#1364) is the one thing still to come
Library 854 Platforms as list and detail (sync, core, BIOS files, removal); Collections as list and detail — the kinds, each kind's collections as a table as described
Settings 854 six sections, list and detail as described; RetroAchievements has no sign-in to hold yet (#1627)
Data Management 854 six populations as list and detail — what this device holds, and what can be taken back as described
Downloads 348 the queue with its controls unchanged

Page is "main" | "sync" | "library" | "settings" | "data" | "downloads". System is gone — its core picker and BIOS files are in Library › Platforms, and the value, the router branch and the menu entry left with it.

The Sync page opens from the menu, from the conditional slot while there is something in it, and from Open Sync on the paused-run notice; Downloads opens from View All in the download summary, which is shown only while the queue is not empty. A notice can carry a door of its own, and three of them name a Settings SECTION rather than the page — Open Controller, Open Connections and Open Updates — but a notice and the slot are both there only while their condition is, so the menu is the navigation a reader can go looking for. A target is a page id, or { page: "settings", section } for those three (frontend/src/types/navigation.ts); the section rides on the page rather than beside it, so no target can pair a section with a page that has none, and the router (frontend/src/index.tsx) stays the only thing that decides what is mounted. A navigation naming no section opens Settings on its first, exactly as the menu's own entry does. Every page but Main opens with a Back chip, which returns to Main. The chip shares its line with the page title — one row, not the three a full-width button plus a title line used to cost, which on the Deck's body is most of what a detail pane has to spend. Back is also on B, and the binding lives in the panel's router (frontend/src/index.tsx) rather than on a page: one Focusable with onCancelButton wraps the mounted content only while page is not main, so every sub-page — wide and narrow — answers B from wherever focus sits, and Main answers nothing, so B does on Main whatever the menu holding the panel does with it. That condition is what makes taking B safe: the escape route is never removed, it is exactly as far away as the user walked in, and the last press is never swallowed. Steam already prints "B ZURÜCK" in its footer legend, which this makes true rather than misleading, so no legend entry of ours is needed. The chip stays as the discoverable half and as the mouse path, and it carries Steam's own B glyph — drawn for the controller in the user's hands, so it is ○ on a PlayStation pad and the swapped face button under a Nintendo layout. @decky/ui does not re-export that component, so frontend/src/utils/deckyUiInternals.ts reaches it by a module probe and types it as possibly absent; the chip falls back to its chevron the day the probe misses. The button number it passes is Steam's own action-button enum (A=0, B=1, X=2, Y=3), not @decky/ui's GamepadButton, where 1 is A — the two disagree on every value, and the wrong one draws the wrong glyph without failing.

A tabbed wide page has to get out of the way for that to work. Steam's tabbed page renders its content pane as onCancelButton: !cancelSkipTabHeader && <focus the tab row> (chunk~2dcc5aaf7.js), so without the flag the first B inside a tab is spent moving focus to the tab row and never reaches the router. WidePage passes cancelSkipTabHeader — Steam's own prop, which it uses in its controller-configurator dialogs, and which upstream's TabsProps predates; frontend/src/utils/deckyUiInternals.ts types it. After a navigation the router scrolls the panel to the top, and gamepad focus is placed where the page opens — by the router for a narrow page, at the area the page declared or at its first stop where it declared none, and by the frame itself for a wide one, which says so on its root so the router leaves it alone (see "Building blocks → Tabs"). The module-level currentPage survives a QAM remount, so reopening the QAM lands on the page that was open, and a wide page re-expands on mount.

Building blocks

Tabs

Steam's tabbed page, switched with L1/R1, for two to four peer views of one page. Wide pages only: at 300 px the bumper glyphs overlap the labels, which is why the Library page's tab bar is hand-rolled today. Only Library has tabs.

Entry focus lands in the content — the active tab's, so the list of a list-and-detail page — and the bumper glyphs follow, because Steam draws them only while gamepad focus is within the tabbed page. Back stays reachable by moving up.

Entry focus belongs to the frame, on every wide page. WidePage marks its root as placing its own, so the panel's router leaves the page alone rather than placing focus of its own, which would land on the Back chip above the body. Where Steam's tabbed page renders, its autoFocusContents does the placing; everywhere else — an untabbed page, and a tabbed one whose Tabs probe missed, which the start-up check does not let a mounted panel reach (Tabs costs the panel, frontend/src/boot/steamModules.ts) — the frame places focus inside the body itself, by the router's own rule and on the same 50 ms delay: the area the body declared, or its first stop where it declared none. The delay is there because Steam's navigation resolves a focus pointer it retained across the page swap after the mount. Opening a page is the frame's moment and its only one: a page whose body changes while it stays open answers for that swap itself, by the same rule and under a condition of its own — the Sync page's left column is the one that does.

The stop it picks is the first enabled focus stop in document order that contains no focus stop at all — one rule, both widths. Document order rather than "the first button", because a page's first button is not its first row, and it is not that on either width: on a wide list-and-detail page whose list rows carry no control, the first button in the body is in the DETAIL pane, so a button-first rule would open the page inside the detail and move as the detail's content changed. Innermost, because a container Focusable that declares a stop — or carries an activate handler, which promotes it to one — has a tabindex="0" of its own and precedes every row it wraps, so the first match would be the container and the reader would start a step away from the row. Enabled, because a page opening on a dead control says nothing about where the reader is — the reveal rules in ScrollRegion read the same shapes and do NOT skip a disabled control, since focus still lands on one.

The two halves read different selectors, and the difference is load-bearing. A candidate has to be enabled, but a container is skipped for holding a stop of ANY kind: a button row whose every button is disabled is still a container Steam does not stop on, so treating it as the innermost candidate would put the focus ring on it and take it off the next real row. The test cannot tell that row from one carrying an activate handler, so it skips both; a row whose only inner stop is disabled is stepped over, which no body's first column produces today. Where that leaves no candidate — no enabled stop that is free of stops inside it — nothing is placed and the page keeps whatever Steam's retained pointer resolves to.

Every page takes the same rule, unless it names somewhere better. A page marks the area entry focus belongs in (ENTRY_STOP_ATTR) and whichever placer opens it — the router, or the frame — picks the stop inside that area with the same rule, so what a declaration changes is WHERE the rule is applied and never which element it picks. Two things declare, for two different reasons. Main declares on the menu's Sync entry: its three status rows act on nothing, so opening on the first of them — Connection, which is where the panel opened before — spends the reader's first press on a move to what they came for. The declaration is what makes that stable. Main's first BUTTON is not the menu whenever a notice carrying an action is on screen, so a button-first rule would open the panel wherever the day's conditions put one; that rule was tried and dropped for the same reason, back when its argument was that a narrow page is one column of Steam's own full-width rows where the first button IS the first row — true of Main only while Main had a Sync button near the top.

A list-and-detail page declares on its SELECTED row, and there the declaration is not a preference about where to land but what makes the page keep the state it was opened with: focus selects on that layout, so entry focus landing on the first row selects the first row. Settings is opened on a named section by three of Main's notices, and before the row was declared each of those jumps mounted the right section and then had it overwritten about 50 ms later — Open Controller landed on Connections. A list opened with no section named loses nothing: it either selects its own first row, which is what the fallback would have picked, or selects nothing and so declares nothing (the Library page's platforms, which additionally are tabbed, so Steam places that focus and the frame places none). Library › Collections opens on its first row, Collections, and declares it anyway — but it is tabbed: wherever the panel mounts, Steam's tabbed page places the focus and the mark goes unread. The frame reads it only on its missed-Tabs branch, which the start-up check does not let a panel reach (Tabs costs the panel).

Downloads is unmoved and declares nothing: it leads with its Back button, which is both the first stop and the first button, so the router's default already opens it there. Data Management needs no declaration of its own — it is a wide page, so the frame places entry focus in the body by the rule above, and on its list that is the first row. Whatever the rule, the root it searches is the plugin's own content and nothing above it — under Decky, its panel title and the back arrow beside it are rendered outside that box, 34 px above it (the same inset whose bottom WidePage's ancestorOverhang measures); behind Tender's own entry there is no such chrome at all, because Steam's tab group renders the panel directly — so no rule here could reach anyone else's. The declaration, the finder, the shared set of shapes and the .focus() + gpfocus pair are frontend/src/utils/entryFocus.ts. It is a second attribute rather than a second use of the wide frame's OWNS_ENTRY_FOCUS_ATTR because the two answer different questions: that one says WHO places entry focus — it tells the router to place none, because the frame places its own — and this one says WHERE, for whichever of them places it. So a wide page carries both, Settings being one: the root says "I place my own" and the list's selected row says "here". The router never reaches the second, because it looks for OWNS_ENTRY_FOCUS_ATTR first and, finding it, sets no timer at all.

A tab's content is the page's business, not the frame's. The frame wraps an untabbed body in a ScrollRegion and a tabbed one in nothing: Steam's tabbed page already wraps each tab's content in this same plain scroll panel, so a region from the frame would only nest a second scroller around it. Rows of one column therefore scroll in a tab with nothing added. A page that needs more than that one scroller — a list and a detail scrolling independently side by side — builds its regions with ScrollRegion itself, which is what Library's tabs do, and an untabbed page that does the same says so with ownRegions so the frame wraps its body in none either.

Scrolling a region without moving focus

A region scrolls by focus, and that is the whole of it for a page a reader walks. A page whose content advances on its own has no focus move to ride on — the sync run walks its own unit rows — so it scrolls the region itself, and names the region (ScrollRegion's testId) to find it. A name rather than a ref: Steam's scroll panel is reached through a webpack probe and nothing establishes that it forwards one, while the attribute lands on the element either way. Such a scroll is clamped to the region's own ends, so a first or last row is left where it sits rather than centred past the start or the end of the list, and a region whose content already fits is not scrolled at all.

Room for the focus ring

Every ScrollRegion keeps 4 px of room inside its own box for Steam's focus ring (FOCUS_RING_REACH in bigpicture/layout/ScrollRegion.tsx). On the branch where the region is Steam's scroll panel, the ring is not drawn on the focused element but over it, from the panel's own focus-ring root inside the region. Measured through CEF in the dev window (855 px wide), in the list column of a list-and-detail page before the room existed: the region's first child is that root — an absolutely positioned element at the region's top left, which Steam's class map names FocusRingRoot — and the size Steam takes for a row's ring (GetBoundingRectForFocusRing on its nav node) is the row's own box, which there was the column's full width and, for the first row, started at the region's top. Read from Steam's stylesheet rather than measured: the ring is the FocusRing class of the same module that exports FocusRingRoot (in css/chunk~2dcc5aaf7.css for the client this was read on — the chunk name changes between Steam builds, the module's two class names are how to find it again), a 2 px outline at a 2 px offset, so it reaches 4 px past every edge of what is focused, and the region clips it at its own box. The ring itself could not be observed through CEF — Steam draws it only in the active navigation context. So without the room, a row spanning its column loses both side edges of its ring, and the first and last rows their top and bottom edges too.

The room is the region's own padding, not something inside it and not something on the outside of it. Outside is ruled out by the region's sideways clip, which is deliberate (ScrollRegion). Inside — a padded element between the region and its content — is ruled out by a child that fills its region with height: 100%, as the Sync page's run body does: a percentage height inside an element whose own height is auto is auto too, and the run body's unit list would lose the bounded height it scrolls inside. The padding is declared with box-sizing: border-box, because Steam's own panels compute content-box (measured on the Quick Access tab panels), under which it would be added to the full height the region is given and overflow the parent. Three things the padding could have disturbed, measured in the Quick Access document of the same client — the first on Steam's own tab panels, the other two on a probe element standing in for a region, a 100 px scroller padded 4 px under border-box, not on a ScrollRegion: the ring root does not move with the padding, because Steam's stylesheet places it with top: 0; left: 0 — a tab panel padded 16 px at the top holds its root at its own top left; a child of height: 100% resolved against the probe's content box (92 px); and the probe's block-end padding counted in its scrollHeight (308 px over 300 px of content), both as a block and as a column flex container — which is what lets scrolling to the end show the last row's bottom edge, and what revealBottom reads.

The fallback branch keeps the room too, for layout rather than for a ring. Where the panel probe missed, the region is a plain Focusable with no focus-ring root of its own, so its box is not what clips a ring there, and where that ring is drawn and clipped has not been looked at. The room stays so that a page's content lays out the same on both branches — and one page depends on it: the Sync page's unit list stands in its column's room with a negative side margin (below), and the fallback's overflow: auto covers both axes, so a column without the room would scroll that list 4 px sideways. That consequence is read from the code, not observed.

Every region built with ScrollRegion gets it, and on the wide pages every region that holds a full-width focus stop is one: both columns of a list-and-detail page (Settings, Data Management, Library › Platforms and Collections), both columns of the Sync page — the preview table's rows in the left one, the option, memory and last-run rows in the controls column — the run view's own unit list, and the frame's region around an untabbed body that builds none of its own, which no page uses today. One region sits inside another: the unit list is a region within the Sync page's run column, and on the panel branch its rows' rings are drawn from its own ring root and clipped at its own box, so the room it needs is its own. It stands in its column's room with a negative side margin of the same 4 px, so the unit table starts where the section title over it does rather than a second room further in. What the room moves everywhere else is the same 4 px on every side of a column's content, which moves a column's rows, headers, titles and button rows together; a column drawn at a fixed width (the list's 264 px, the Sync controls' 270 px) keeps that width for the box and gives its content 8 px less. Entry focus reads the DOM for stops and is untouched by it.

What is not a ScrollRegion gets none. A tab that builds no region of its own sits in Steam's ScrollingTab (no tab does today), and the narrow pages sit in the Quick Access panel's own scroller. The removed-games cleanup dialog has two scrollers of its own — the dialog body and its details region (bigpicture/RemovedGamesCleanup.tsx). Whether a full-width row in any of these loses the edges of its ring has not been looked at.

Columns

One row of side-by-side scrolling regions, which is what every wide page whose content is more than one column is built from: a Focusable the stick crosses horizontally, and a ScrollRegion per column. A column names a fixed width or takes what is left, and may name a region key — joined to the column's id to form the React key its region carries — so that changing it remounts that one column and its content opens at its own top rather than at the offset the previous content was left at. A column that names none keeps one constant key and is never remounted. A key rather than a ref that scrolls the region back: Steam's scroll panel is reached through a webpack probe and nothing establishes that it forwards one.

List and detail is Columns with two columns. The Sync page's table beside its controls column is the next.

The pane primitives

The pieces a detail pane is built from, in frontend/src/bigpicture/layout/pane.tsx so that the next pane is written against the same scale rather than a second literal for the same size: the 11 px every secondary line is set in, the verdict palette, the two button shapes (FLAT_BUTTON for a button sharing a row, ROW_BUTTON for a table row's action column), a section title, a muted line, a row of buttons, and the two lines that report an action — the status line bound to the entry and the group it belongs under, and the sentence saying which other entry is working while this pane's buttons are disabled.

List and detail

The list takes about a third of the width (264 px in the prototype), the detail the rest. Focus selects: moving through the list changes the detail at once, as Steam's own settings do. A list row may carry a toggle; A operates it, never the selection. Both regions scroll independently inside the page's measured height, and both scroll by moving focus — so a detail pane is built from focusable rows, not from paragraphs.

A list whose rows carry no control of their own — a label and nothing else, which is what Settings and Data Management have — asks for selectOnActivate, and every row wrapper takes an activate handler that selects it. That handler is what makes the wrapper a focus stop rather than a container, so without it those rows are unreachable and the list cannot be scrolled. It is off by default, because a row that does carry a control must leave A to it. A row may name its own answer, overriding the list's, for a list whose rows differ — Library › Collections, where two rows carry a switch among rows that carry nothing. A row that names none follows the list.

A list that is grouped or sorted by state computes its order when the page mounts and keeps it while the page is open, so toggling a row does not move it out from under the focus. The next mount shows the new order.

A control that acts on the whole list — Enable all, Disable all — goes in the layout's listHeader, above the first row and inside the same scrolling region. It sits outside every row on purpose: focus moving onto it must not report a selection, because a page may do real work on one.

It spans exactly what a row spans, and that span is not symmetric: a row is inset on the left by its own selection marker (a 3 px bar and a 5 px gap) and runs to the right edge of the list's content. Steam's Field, which every row is built from, adds nothing horizontally inside the QAM — it renders in its Classic mode there, whose only padding is 10 px top and bottom — so there is no Steam inset to match and a symmetric padding on the header is simply narrower than the rows. The header and the rows sit inside one element, so they move together whatever inset it sits in — including the room each pane's region keeps for Steam's focus ring (§ Room for the focus ring), which this layout adds none of its own to.

Tables

Anything with more than two facts per row is a table with a header row: BIOS files (File, On disk, Contents), the preview (a row per platform; New, Updated, Removed), registered devices, cleanup candidates, a kind's collections. Those facts were once folded into a field's label and description, which is why #1803's third axis had no slot on the rows the System page drew; the platform detail's BIOS table is where that column now sits.

There is one table, and a page passes the register it is set in (PaneTableHeader / PaneTableRow in frontend/src/bigpicture/layout/pane.tsx). The shape is shared — a grid of a page's own columns, an 8 px gutter between them, the header's names in the secondary size and colour, a row that is a focus stop, a cell that clips — and what a page varies is the type size, the leading, the row and header padding, and whether a hairline sits under the column names. That is a TableRegister, and the default is what a pane uses unless it says otherwise. It is one component rather than three because three drifted: the same header was written three times, and only one of the three clipped its cells.

A row is a focus stop unless one of its own cells carries a control — the BIOS table's action column is the case, and there the button is already the stop, so a second one on the wrapper would put a dead step in front of every one of them. A cell opts out of the clip for the same kind of reason: a cell of glyphs has nothing to ellipsise, and a cell holding a button must not hide the overflow its focus ring is drawn in.

A cell clips; it never overflows. A grid track sized minmax(0, 1fr) shrinks under its content and the content then spills across the track beside it — on the Deck a platform name and its note ran into the New column's digit. The clip (overflow: hidden, text-overflow: ellipsis, white-space: nowrap, min-width: 0) belongs on the cell, because a grid item is blockified and those properties apply to it, where an inline span nested inside it is not and the same three do nothing at all. What the clip takes away is handed back in a title, note included.

Destructive actions

Last in their group, red, behind the confirmation they carry today — two-tap or modal. Nothing here changes the backup-or-confirm rule in the invariant register.

Text input

Two kinds of text input, and they go in different places. A value input opens a modal — Settings' RomM URL, custom headers, account, SteamGridDB API key and default slot: the reader types a value, confirms it and is done, nothing on the page has to be seen while typing, and the on-screen keyboard needs the room a page cannot give. A search that filters a list sits inline, above the list it filters — Library › Collections, and the whitelist on Data Management's Other non-Steam games pane: its point is the result beside the field, narrowing as the reader types, and a modal would hide exactly that. What decides is what the reader has to see while typing, not how much room the keyboard wants.

Notices and homes

A notice on Main names a condition and jumps to its home; the action exists only there. A condition with no home in the plugin stays a card without a jump, with Dismiss where the condition has a sensible end.

Condition On Main Home
Settings were reset text, backup path, Dismiss none — the card is the whole of it
Cross-device playtime needs a fresh sign-in text, Open Connections, Dismiss Settings › Connections, where the accounts are
RetroDECK paths missing or unreadable warning card, no action none — the fix is outside the plugin
Steam answers for no notifications warning card, no action none — the fix is outside the plugin
RetroArch input_driver is wrong text, Open Controller Settings › Controller, which holds the Fix button
Sync paused on the session budget text, Open Sync Sync, which holds Restart Steam now and Resume
A newer Tender release is out both versions, Open Updates, Dismiss Settings › Updates, which states both versions and holds the check's switch and Check now

Every row of that table is what the panel does today. The two full-page states — a version error and a pending RetroDECK migration — are not notices; they replace the page, and neither carries a condition inside it any more: the one that did was the pre-rename plugin folder, which went with the plugin loader.

The notifications row is the one condition read from the start-up check's report rather than from a backend answer or an event. What a toast is raised through is two searches into Steam's bundle and a global Steam installs at module scope (how the panel is built and loaded), so a miss is settled before anything mounts and cannot change afterwards: there is nothing to subscribe to and nothing to poll. It is a notice rather than a refusal to mount because the panel is entirely intact without it: syncs and downloads run, and every result a toast would have announced is on the page it belongs to.

The playtime notice is the one that carries two buttons, and they sit side by side on one row rather than on two full-width ones: Main is the narrow page, and a notice costing three rows pushes the status block it sits above off the screen. Its jump is not an answer either — only a fresh sign-in ends the condition, so Open Connections leaves it standing and Dismiss remains the way to put it away for this view.

The update notice is the other one with two buttons, side by side for the same reason, with no horizontal padding so each label fits on one line, as the Platforms tab's Enable all pair does. Its Dismiss is per version: it records the version the card names (update_notice_dismissed_version), so the next release raises the card again, and Check now in its home forgets it. The home states the versions and holds the check's switch and Check now; it installs nothing, and to a run from a checkout it shows a line naming this a development build. The card's condition is available on the backend's answer and nothing else — a newer release with its tarball and a valid digest attached, not the dismissed version, the check switched on. The answer is fetched at panel load by a detached call nothing awaits (the store's fetchUpdateNotice says why), and rewritten by Dismiss, the switch and Check now.

Four of the seven conditions above carry no Dismiss anywhere — RetroDECK paths, the missing notifications, the input_driver fix and the session budget — so the absence is ordinary.

Main

Narrow, in this order: the settings-reset and playtime-scope notices, each a titled section of its own, both above everything else; the status block — the RetroDECK warning and, where Steam answers for no notifications, the warning that says so, then Connection, Last sync, Library, then the conditional slot and, while a run is going, Cancel Sync, then the transient line a just-ended run leaves behind (and a cancel whose call failed), and under all of those the three notices that carry a button (the RetroArch input driver, a run paused on the session budget, a newer release); the download summary (up to two rows, an overflow count, a completed count, View All); the menu — Sync, Library, Settings, Data Management. Those last three blocks carry no section title at all — what separates one from the next is a hairline (BlockSeparator), which costs one pixel of height where a heading would cost a whole row. The layout study it was chosen from is main-layouts.html.

The menu is the navigation that is always there — complete, and always in the same place. The status rows state and do nothing. The single exception is one conditional slot that exists only while the Sync page has something to report; a notice can carry a door too, and it comes and goes with its condition exactly as the slot does. That is the whole rule, and everything below is what it costs and what it buys.

The panel opens on the menu's Sync entry, which Main declares as its entry stop rather than letting the router's default land on the first row of the status block — the rows below state and do nothing, so opening on one spends the reader's first press. What that costs is Steam's own scrolling, and it is a real cost rather than a theoretical one: Steam scrolls the focused element into view, so on a Main tall enough to scroll — a notice or two, an active download — the panel opens part-way down, with the notices that wanted attention off the top. Main fits today with three status rows and a four-entry menu, so nothing scrolls; it is not defended against in code, because a rule that opened somewhere else depending on what is on screen is exactly what the declaration replaced. The mechanism is under "Building blocks".

The three status rows act on nothing. Connection, Last sync and Library are Fields that carry no activate handler, and they stay focusable all the same — a region scrolls only by moving focus, so a row nobody can focus is a row nobody can scroll to, and with the panel now opening below them that is the only way back up to them. Last sync states the newest completed run's age, and where a newer run did not complete it states that run's outcome and age too — "cancelled 12m ago", "interrupted 3h ago" — on a second, quieter line. With no completed run ever, that attempt is the only line, so the row never reads a bare "Never" after thousands of games synced (#1318). Reporting a run and offering to continue it are different questions: an errored run is reported here and is not resumable, which syncResumeState decides for the button that offers it, on the Sync page.

The conditional slot sits under the three rows and is the one status row that can be pressed; pressing it opens the Sync page, exactly as the menu's Sync entry does. It is not the only pressable thing in the block — Cancel Sync joins it while a run is going, and the notices that carry a button sit below both — but it is the only one that is part of the status. It exists on two occasions and no others:

Occasion It says Bar
a run is in flight Checking for changes or Syncing, and the step counter yes
a preview is pending Changes ready, and its counts — "13 new · 4 updated" no

Coarse means coarse: a short label, a counter and a bar. The stage caption, the fine-detail line, the estimate and the per-unit table all stay on the Sync page — Main says what is, the page shows what is happening. All three come from the frame, and the label is stated on it rather than derived from it: a preview run and an apply run narrate the same work queue through frames of identical shape, so neither the stage nor the presence of a plan is evidence of the kind — the stage alternates fetch/apply inside one apply run, and a plan's absence conflates "this is a preview" with "nobody has established the kind yet". So the backend claims the kind with the run slot (LibrarySyncStateBox.try_begin_run) and every frame of that run carries it as runKind: the live event, both terminal frames, and the get_sync_status snapshot a remounted QAM re-seeds from — which is what makes a QAM reloaded mid-run right rather than guessing. Where no kind is stated the slot says neither of the two, wording it "Sync in progress"; the numbers beside it still come from useSyncRunView, which the Sync page reads too, so one derivation of a run serves both pages.

A pending preview's counts drop their zero parts, and one with none of the three names the work it does hold rather than showing a row of zeros — there being one line to spend, and that being the case where the counts cannot spend it. Two of those previews can be named: collection changes where the sync would add, drop or re-populate one, and cover work only where the delta is cover refreshes. A preview holding both reads as the collection, because that is the one whose result the reader will see in Steam. The rest keep the plain ready to review — a platform re-stamp, which has nothing a reader would recognise to name, and a genuinely empty delta, which has nothing at all — and the page behind the slot is where it is said which. An expired preview counts as none; the backend drops one past its 30-minute TTL (PREVIEW_MAX_AGE_SECONDS). What ticks for that is a single timer aimed at the deadline, not a per-second interval: nothing on Main counts a preview down, so the only moment the clock changes anything here is the one the slot disappears at. Main never discards a preview, and since it can no longer start one either, it holds none of the paths that answer the preview question — the invariant register's pending-preview entry names all of them, and every one is the Sync page's.

Cancel Sync sits directly under the slot while a run is in flight, and nowhere else. It is an action on the thing being displayed rather than navigation, so it does not break the rule above; the alternative was two presses to stop a run the panel is already showing. It disarms into "Cancelling…" for the backend's RUNNING → CANCELLING → IDLE drain and is re-armed by the run stopping (#1202, RC-B).

Nothing on Main starts a sync. There is no start button, no resume button, and no preview is computed here — the Sync page's own button does all three, and that is where the progress, the answer and, above all, a refusal are reported. Resuming a cancelled or interrupted run therefore costs two presses, which is accepted rather than an oversight: Last sync says the run stopped, and Resume is a button one press away on the page that also holds the run history the reader is about to want.

What Main still owns is the run's END. useSyncRunView hands that back as two callbacks and only the owning page passes any, or a second page reading the same run would announce the end a second time: the once-per-run announcement, with the stats re-read it provokes and the ask for a preview the run may have staged, and the correction that follows when the run's own terminal frame arrives with better wording. The transient status line those write into stays on Main and is not gated on the run being idle — a cancel whose CALL failed leaves the run in flight, and its "Failed to cancel sync" has to reach the reader under the rows it is about. The Sync page passes neither callback and reads the same numbers; what it keys on the run's end for is its own three reads — the run list, the stats and the session-budget reading all describe the run that just stopped — taken on a stop that carries a terminal stage rather than through a second announcement of it. The stage is what separates a run's end from that page retracting its own optimistic frame after a preview: both stop the store's running, and only one of them ended a run.

Steam memory is not on Main. The reading and the session-budget card are on the Sync page, at the home of the button they are about; what stays on Main is the notice naming a paused run and pointing at it. Main reads the session-budget store not at all — its one remaining poll re-reads the stats while the last run is paused, so the notice goes away again if the run's terminal refetch was ever missed (#39).

Alongside the hook, runUnitsStore.ts holds one run's work queue per unit: a row per unit, seeded from the plan and bound to the run id the plan carried, advanced to running and then done by that run's frames, and carrying what the unit's apply created and updated. The binding is what keeps a later run off an earlier run's rows — a preview emits a frame per unit over the same queue and no plan at all, so the step index alone would walk them a second time. The store outlives every page, so a page opened mid-run can show the units already worked through rather than only the current one. Main reads none of it — its slot is the frame and nothing else.

Sync

Wide, untabbed, and it owns its regions: two Columns — the left flexible, the right 270 px — each scrolling on its own inside the frame's measured height. The layout study it was chosen from is sync-layouts.html.

The left column shows exactly one of three things, and the order they are decided in is the order of authority. A run in flight owns the page: the progress rows are the true state of the machine at that moment, and a preview held while one is going is not dropped — the store keeps it and the table comes back when the run ends. Then a pending preview. Then the line saying nothing is waiting, with the button that changes it. The session-budget card sits above whichever it is, and only while no run is going: a paused last_attempt survives into the resume that clears it, so the card would otherwise stand over the very run it is asking for.

A swap that takes the reader's focus with it hands focus to the body that replaces it; a swap that does not leaves focus alone. Both halves are one rule (useEntryFocusOnBodySwap in utils/entryFocus.ts), and it runs in both directions: Apply Sync removes the button the reader is standing on, so focus lands on Cancel Sync, and the run ending removes Cancel Sync, so focus lands on the start button of the idle body — or on Apply Sync where a preview was held while the run went. Where it lands is the frame's own rule (firstBodyStop, on the same 50 ms delay), applied to the body rather than to the column, so the session-budget card above it is never what the column lands on. The other half is the point: a reader who has crossed to Options, Steam memory or the run list chose where they are standing, and a body changing behind them must not yank them out of it — which is what Force Full Sync does, ending the pending preview from the controls column. So the question the rule asks is whether the element that was standing in the BODY has gone with it, held as a note while focus moves through the body; it never asks who holds focus after the swap, because a swap is not the only thing that can take focus in one commit — Force Full Sync goes dead in the same one that ends the preview. The mount is not a swap: a page opened mid-run is opened by the frame, on the same stop.

A body can swap twice inside the 50 ms, and the placement follows the last one. Working out a preview does exactly that: the backend stops the run with its own "Preview ready" frame before the sync_preview callable answers, so the column goes run → idle → preview in two commits milliseconds apart, and each cancels the placement the one before it scheduled. So the note the rule reads is spent by the placement it causes rather than by a swap that merely observes it, and the timer asks which body it is landing in when it fires. Otherwise the first of the two swaps spends the note, the second cancels its placement and finds nothing to answer, and the reader is left with no focus at all — which is what the device showed (#1814). A placement that lands on nothing puts the note back: the idle body between those two commits has one button and it is disabled while the call is open, so that attempt places nothing and the swap it was taken for is still unanswered.

The button says what the press does. With Skip preview off a press works out a preview and adds nothing to Steam, so the button reads Check for changes — deliberately the words Main's conditional slot shows while that run is going, so the button and the state it produces read as one thing. With it on the press starts the run itself, and only then is the name the resume question's answer: Sync Library, or Resume Sync where an incomplete run left work the next one can skip. The line above the button says which of the two the press is — "Nothing is waiting to be applied. Start a preview to see what would change.", or "… Skip preview is on, so Resume Sync applies changes without showing them first." — and it QUOTES the label rather than spelling a name of its own, exactly as the session-budget card does and for the same reason: neither may name a button that is not on screen. The resume is not lost to a button that stops naming it. The scope line under it still says how much there is — "353 games already synced — a resume continues from there." — and what a press keeps or clears is decided by the resume question itself, never by the name that press happened to carry.

The preview is a table. One row per platform the backend reports a change for (Platform, New, Updated, Removed), one for the RomM collections built from the added and removed names, one for the Steam collections the sync keeps per platform wherever platform_collection_diff reports a change, and a total row. The total comes from the summary's own counts rather than from adding the rows up: the platform rows sum to it by construction, and the two collection rows count collections rather than games, so each carries what changed on its second line and an em dash in every game column. That leaves the total free to read 0 0 0 over a preview whose only change is a collection membership, with Apply Sync live under it, so a line beneath the total states what the columns cannot carry — "plus 1 collection added", "plus 2 platform collections changed" — built from the same two diffs and shown only where one of them has something to say. The platform-collections row is drawn on the backend's own has_changes — the field the Apply button's condition reads too. The empty-preview sentence takes that same condition (previewHasChanges) as an argument rather than deciding the question a second time, so a leg of it with no wording of its own falls to a generic line and the table can never read "Everything is up to date." while Apply stands over it. A synced: false row is marked rather than hidden. Where platform_breakdown is absent the page says so and draws the totals alone; it never reconstructs a split it was not sent. Under the table: the run's scope and estimated duration, the hint about progress being saved (with the sleep caveat past ten minutes), and the pause advisory when the backend expects one. The deadline rides the section title rather than taking a line of its own, because on the Deck the column has about four rows to spend and the table is what they are for. A preview with nothing in the table — cover-only work, a re-stamp, or a genuinely empty delta — reads as one sentence instead of a table of zeros, and the first two still have an Apply to press. The import choice (#1364) goes under the table, later.

Three buttons end a preview, and each ends it on both sides: Apply Sync, Refresh (discard, then work out another — the third path, new here) and Cancel. They are the three the reader chooses between; a fourth ending is the page's own, and it is Force Full Sync, below. Apply is rendered and disabled rather than hidden where there is nothing to apply, or where the preview has expired; past the deadline the table stays and Refresh is what moves. They sit above the table, under the section title, and the reading is "here is what you can do — and here is why". That is also the only place a controller can reach them from: every row of the table is a focus stop and a region scrolls only by moving focus, so a button row under a fifteen-platform table is fifteen stick presses from where the column opens, which is where the frame puts entry focus — on Apply Sync, or on Refresh where Apply is dead. The expired sentence travels with the row, directly above it, and it is the only line that does: it explains the title's amber "expired" and names the button to press instead, so it is a caption on the buttons rather than on the evidence, and under the table it would have put a dead Apply a whole library ahead of its own reason. The three lines listed above as sitting under the table stay there — each describes the run the table is about.

While a run is in flight the column is the run view: the whole run as one bar under the stage caption, with the step counter and the estimate on the section title beside it, and under all of it every unit of the plan — Unit, Status, Result. Done rows show what their apply produced, the running row shows its stage with its own bar from withinUnitFraction, waiting rows show what the plan holds for them (an expected skip is worded as the prediction it is, never as the run's verdict). Cancel Sync sits directly under the bar, above the list — the bar, the stage and the button that stops the run are one thing, and the table under them is evidence rather than a control; it is also the only place sixteen units of plan do not stand between the reader and it, exactly as with the preview's three. The unit list is a scrolling region of its own, taking what is left of the column under the bar and that button: a plan of seventeen units is taller than the Deck's column, and without it the running unit walks out of sight below the fold. Nothing moves focus during a run, so the page scrolls that region itself and puts the running row in the middle of it, clamped to the list's own ends. Both tables are the pane's own table (§ Tables) set in one flat, small register — COMPACT_TABLE_REGISTER in layout/pane.tsx, which Library › Collections' table uses too. The numeric-column split and the pieces that are not tables at all live in sync/paneTable.tsx.

Focus lands on Cancel Sync when this body takes the column, by the swap rule above: what it picks is the first stop holding no stop of its own, and every unit row below is a stop too, so what puts it on the button is the button being first — the same ordering the column is laid out for. On the swap ALONE: a run re-renders per frame and none of those is a moment to move the reader.

A run that has ended stays ended. The frames both pages render come from one module store (utils/syncProgress.ts), and the frontend writes to it as well as the backend: the apply loop stamps a frame per shortcut. That loop cannot stop the moment a run does — it tests the cancel flag at the end of an item, and the item it is inside was entered from a shortcut scan that takes seconds — so at least one frame is always written after the run is over. On the device that frame was the LAST thing in the store, four seconds after a cancel, and the page stood frozen on a run that had ended, its Cancel stuck on "Cancelling…", until it was left and reopened (#1814). So the store refuses it: once a stopping frame carrying a terminal stage has named a run, nothing can put that run back in flight. Two more writers have the same shape — the cover-refresh loop and the chunk seed — and the cancel flag they consult is not even set for an ending nobody asked for: a heartbeat timeout, a budget pause, a backend error. What the rule takes as a run's ending is a terminal stage AND a run id, because neither half alone is one: a stop without a terminal stage is a page retracting the optimistic frame it wrote itself, and a frame naming no run is one the backend has not stamped yet, so recording that would make every later optimistic start a resurrection of it.

The bar and the counter come from useSyncRunView, the rows from runUnitsStore. A run with no rows — a preview, which seeds none, a run whose plan was lost to a plugin reload, or the window between a press that cleared the rows and its plan arriving — shows the frame's own fine-detail line in their place ("Fetching Game Boy Advance (page 12/62)") — useSyncRunView's own fineDetailText, which no other surface renders; the page says the per-unit detail is unavailable only where there is neither a row nor a detail line.

The rows are cleared at the press that starts a run, and kept at exactly one press. A plan is the only other thing that replaces them and it arrives late — after the work queue is built on the two apply paths, and never at all on the preview path — so without a clear the previous run's units stand over the new one: its done rows still carrying that run's apply results, and the unit it died in dressed as running by the frames of this one. A resume is the one start those rows are still true for, because they are the progress it continues from. Nothing on the wire tells a resume from a fresh start — it is a new run with a new id, and the backend has no resume concept at all; what carries one is the per-unit skip gate, a fetch-time decision — so the discriminator is the page's own reading at the press, syncResumeState(stats).canResume. That is the resume question itself and not the button's name: with Skip preview off the button says "Check for changes" over a resume the rows are still true for. A press landing before the stats have answered reads as a fresh start and clears: not knowing is not evidence of a resume, and the cost of being wrong that way is the fine-detail line for the pre-plan window rather than another run's units.

Every row of both tables is a focus stop — a Focusable with an activate handler — because a region scrolls only by moving focus, so an unreachable row is an unscrollable one.

The right column, always: Options — Skip preview as the persisted setting, and Force Full Sync, red, last in its group, behind a ConfirmModal stating that it forgets what was synced and rebuilds everything. A successful clear ends the pending preview on both sides, the way Cancel does: the clear has just discarded the state that preview was worked out against, so applying it afterwards would skip exactly what the clear armed a re-fetch for. It is the fourth path that ends a preview, and the only one the page takes rather than the reader.

Force Full Sync is rendered and disabled, never hidden, and the line under it says which state it is in rather than describing a press: while a run is in flight; while a clear already made is waiting for its run, since pressing again would clear nothing (that state ends when a run has been and gone); while the stats say nothing has been synced; and while the stats have not answered yet. Those last two are both a null snapshot and they part here. A read still on its way disables the button, since not knowing is not evidence that there IS something to clear. A read that failed leaves it live and says the reading is missing, because a failure is not an absence. The page's one unconditional stats read is the one on mount — every other is conditional on something happening (a run ending, a clear, a poll while the last run is paused) — so a mount read that never answered used to leave the button dead for as long as the page stayed open, which is what a cancelled preview left behind on the device (#1814). Skip preview stays live throughout: the setting is read by the next press of a start button, so flipping it during a run changes nothing about that run. Steam memory — the reading now and the last run's delta, saying "unavailable" and "not recorded" rather than showing a zero. Last runs — the ten newest, newest first, each a focus stop with the start time, what the run covered and how it ended.

Working out a preview is this page's call, and only this page's. Nothing computes one on open — not this page's own mount and not Main, which starts no run and computes nothing; its one sync control is the Cancel that ends a run already going — so a preview happens because the reader pressed the button that asks for one, right here. Everything the call can answer — the run while it works, the table when it lands, and a refusal — is therefore reported where the reader is looking.

What the backend holds for it: the preview answer carries library-wide totals (SyncPreviewSummary: new, changed, unchanged and removed counts, the platform and collection counts, and more), the names of new and changed games, and the added and removed collection names (collection_diff). The same counts split per platform ride the summary as platform_breakdown — one row per platform holding at least one non-zero count, ordered by display name, each carrying synced for whether the platform is in the run's platform list. A synced: false row is a platform outside it: its toggle went off, RomM stopped listing it, or the only route to it is an enabled collection, which is not filtered by platform enablement. The causes compose, so one row can carry removals for the ROMs the run no longer fetches and new or changed counts for the ROMs a collection still reaches. Its name is the run's where there is one, else a real name carried on one of the platform's fetched entries — a reconstructed collection member carries the slug there and does not count — else what the backend recorded, and the bare slug where no tier answers. There is no collections row there: collection_diff on the same summary already carries the added and removed collection names. The Steam collections kept one per platform are a third field, platform_collection_diff — has_changes and an added and a removed count, no names, which is why the page's row for them states counts where the collections row states names. get_sync_runs answers the ten newest sync_runs rows of any status, newest first, each verbatim from the SyncRun aggregate (id, started, finished, status, planned counts, completed platforms and collections, error) — a field a run never recorded stays null, and the status is what says why. Skip preview is a user-intent setting in settings.json written by its owner (adapters/persistence.py) and reported by get_settings. No backend sync path consults it: the choice between asking for a preview and starting the run is made on the frontend, by this page's own start button.

Library

Wide, two tabs.

Every sync write on either tab is optimistic, and a write that does not take says so. A switch shows its new value before the backend answers. A refusal or a rejection is one outcome — none of the write callables throws to refuse. The switch goes back and a line says why, both within the rule below; the line carries the backend's own message, or a short fixed sentence where there is none (SYNC_WRITE_FAILED), and takes no space otherwise. Without the line a switch that goes back is indistinguishable from one that never moved. A later write in the same place that succeeds takes the line back, within the rule below. Which writes each tab makes, and where its lines sit, is under the tab below.

Only the latest write speaks — on a switch's value, and on a line. A switch — a platform, a collection, the owner toggle — keeps the value last stored for it, taken from the read and moved on by every write that succeeds, and numbers the writes issued to it. Enable all / Disable all number every switch they write. An answer changes what the switch shows only while its write is still the latest for that switch: a success leaves the value the write showed, and a failure puts back the stored value. An answer that is no longer the latest only moves the stored value on, when it is a success, and never touches what is shown. So two refused writes to one switch leave it showing what is stored, a refusal of an older write cannot undo a newer one still in flight, and a refused Enable all puts back only the switches no later write has touched. A line numbers the writes issued in its place, and an answer sets or clears that line only while it is still the latest write there and the tab has not been entered again since it was issued; entering a tab also clears its lines. So a slow refusal cannot stand a line over a write that has since succeeded, a slow success cannot take back a newer refusal's line, and nothing lands on a view entered afresh. The bookkeeping is frontend/src/bigpicture/library/latestWrites.ts, shared by both tabs' hooks.

Platforms is list and detail. The list holds every platform RomM reports with at least one ROM — what get_platforms returns; a platform with nothing to sync is not listed — in two groups, Synced (the toggle is on) above Available, each alphabetical: a dot, the name, the toggle, and nothing else. The dot is the row's whole BIOS signal, through the shared mapping every platform-level BIOS dot renders through (frontend/src/utils/biosColor.ts: green complete, amber partial, red missing, grey for a missing level; the per-file rows on the platform detail and the game page hard-code the same four colours). It is drawn on every row, taking exactly the helper's grey where there is no level to state: one that came and went shifted every name beside it, and the list is meant to be scanned down its left edge — which matters more now that the dot carries the state alone rather than reinforcing a number beside it. What the dot means in words is the row's title, and it is literally the pane's sentence: both come from biosSummary (see the BIOS files bullet below), so the number the row used to print arrives inside that sentence wherever the state has one.

The row carried the ratio (3 / 5, an em dash where nothing is required) until the second device round, and that is superseded rather than forgotten. The first device round asked for it and it was added; using it decided the opposite — a number in a line you scan past earns nothing when the pane one keypress away states it properly, with the files it is made of. The layout study still draws it; on this point the study is superseded, and so is the earlier round's finding. Do not restore it as a regression. Enable all and Disable all sit above the groups, in the list column and outside every row, so reaching them reports no selection. The order freezes while the page is open.

The sync writes are a row's toggle and Enable all / Disable all, which write every row in the list, and they follow the rule at the top of § Library (Only the latest write speaks). Both report in one line, under those two buttons: in the list column, scrolling with the rows, plain text rather than a focus stop. Either write is refused while a RetroDECK path migration is pending, and Enable all / Disable all also when the RomM listing they need fails.

The detail offers no sync control of its own — the row already is one, focus is already there and A works the toggle, and the list's two header buttons act on every row at once — so it opens with one header line instead of a Sync section: the platform's name, N on RomM · M in Steam · <core name>, and the core picker's icon button, right-aligned.

Both counts on that line are ROM files. N is RomM's own rom_count for the platform; M is reachable_count — the platform's reachable ROMs (every member of a sibling group that holds a binding, because one shortcut serves the group (ADR-0021 §2) and the game's page switches versions across it), less the versions RomM no longer serves (below). M is not the number of shortcuts: a fully-synced 665-ROM platform behind 458 shortcuts reads 665 · 665, where counting bindings read 665 · 458 and so reported 207 games as missing when none was. The number of shortcuts is count on the same payload, and it is what the Remove group says and acts on — the two must not be folded, or the button offers to remove more shortcuts than exist. Where a whole game never reached Steam the two halves genuinely differ (3084 on RomM · 8 in Steam for a platform with one applied game), and that difference is the line doing its job.

Two things the line does not claim. The halves count different populations — the left is what RomM holds now, the right is what our own rows say — so ROMs added on RomM since the last sync widen the gap, and equality means "nothing outstanding as of the last sync" rather than a fresh server-side proof. And a version RomM no longer serves is reachable but not counted: nothing deletes such a row — ADR-0007 keeps it as an identity anchor and only the removed-game cleanup removes one — and its group's shortcut still reaches it (CONTEXT.md → Reachable), but the right half does not count a version RomM has stopped serving as in Steam. reachable_count is the reachable rows less those the fetch its completion stamp records did not return, which domain/fetch_generation.py::prune_candidate_ids already answers for the cleanup's own discovery: every row not carrying the fetch generation the platform's completion stamp recorded, a row carrying none included. Where no usable stamp exists — none, one with no generation, or one recording an empty fetch — it names nothing and every row counts, so the exclusion's worst case is the number printed before it.

That leaves one window in which the line can read right > left. A ROM deleted on RomM drops out of the left number at once, while its row, where its group holds a binding, still carries the generation the stamp recorded and so still counts on the right. Short of removing the platform's shortcuts, which takes the platform out of the payload, only a sync that applies that platform closes the window: the stamp and the rows' generation are recorded only by an apply's commit (Backend Architecture, "Incremental skip", and domain/fetch_generation.py), and a preview writes neither. So the window survives a sync that ends at "Everything is up to date.", and such a sync is possible: an unbound version deleted from a group that still holds a binding changes no shortcut, and the preview's removals count bound rows only. An apply that stops inside the platform, however it stops, leaves it open too, and wider: it deletes the stamp at its start, and with no stamp every reachable version RomM no longer serves counts again. Closing it without a sync would need a live server call, which this read deliberately does not make — get_registry_platforms answers offline, and that is what keeps the pane useful with RomM unreachable.

The exclusion also means reachable_count is not bounded below by count: a bound row the stamp's fetch did not return raises the shortcut count without raising the header, so a pane can read 2 on RomM · 3 in Steam beside Remove 4 shortcuts. Two shapes reach it — a bound version deleted on RomM, in the gap before that run's stale-removal scan, and a collection-added row on an already-stamped platform, which commits with no generation, so the exclusion leaves out a row RomM still serves — and both heal at a sync that applies that platform: the second at the platform's commit, the first at the stale-removal scan of the run that opened it. A stopped run skips that scan, and later runs skip the unchanged platform, so after a stop the first waits until the platform is fetched and applied again (for instance when RomM changes it, or after Force Full Sync). The direction is a conservative under-count, which is why it is recorded rather than guarded.

The BIOS ratio is not on that line — it was, and its width is what wrapped the line three times on a platform with a long name and a long core label. It is stated once instead, beside BIOS FILES eight pixels below, in the colour biosColor.ts gives the list's dot, so the two places that state a platform's BIOS state agree by construction. Under it, for the focused platform:

  • Emulator core — a microchip icon button in the header line, opening the same context menu the game page uses (buildEmulatorMenu). It is the game page's own button and its own colour coding: grey #8f98a0 when the active core is the default option, gold #d4a72c when it is an override, read off the payload's is_default for the option carrying active_core_label. The core clause beside it takes the same two colours from the same condition, so the name and the icon cannot disagree. A full-width button under the header, with the save-compatibility caveat under that, is what this replaced: two rows for one action, on the pane where rows are the scarce thing. The caveat is not lost — buildEmulatorMenu renders it as the menu's first item, so the copy on the page that opens the menu was the same sentence twice.

    The clause names the core; "Default" is not one of the names it can take. resolve_platform_label answers with the real label in both ordinary cases. null means no option is bakeable, which is not the same as there being none, and the two are different sentences:

    • Options exist, none bakeable, and the fallback can run — the plain RetroDECK launch is baked and RetroDECK resolves the emulator itself. The clause reads RetroDECK decides in the muted colour and the line under it says the plugin cannot pin one; neither promises a launch, because what the fallback then finds is between RetroDECK and the machine.
    • Options exist, none bakeable, and the fallback is not installed — the same unpinnable state, with the opposite outcome. run_game.sh takes command[1] for the system when no alternate emulator is set and options_to_payload keeps ES-DE's document order, so emulators[0] is that command; when its own reason is not_installed, the fallback names a binary that is not there. The clause reads no emulator installed in red and the line names the emulator RetroDECK would have used. Only downgrade_if_not_installed ever sets that reason, and only on an otherwise-bakeable option, so the branch fires exactly where the first command's standalone emulator is missing. A first command unbakeable for another reason (quoting) whose emulator is also missing keeps the muted sentence — macintosh is that shape — because installedness is not established for an option that was never bakeable. Apple I is the muted case, not this one: ES-DE gives it two live commands, both MAME, and the first is a libretro one whose core is installed, so it reads RetroDECK decides and its games start. The three standalone entries in that block are commented out and are not commands at all.
    • No options at all — _resolve_system falls through to the raw RomM slug for a platform its map does not name, and get_emulator_options answers available: true with an empty list for a system es_systems.xml does not list; vic-20, acorn-electron, nintendo-dsi, ps5, browser and win are in neither. RetroDECK's own launch then reads command[1] for the system, finds nothing, and exits 1 (libexec/run_game.sh). The clause reads no emulator in red and the line says the games will not launch, because they will not.

    The chip is disabled for all three, never withheld. Printing "Default" for any of them said the plugin had chosen; printing no emulator for all three said the games would not start where they do. Both were wrong, in opposite directions, and the middle state is why the split is three rather than two: it is unpinnable like the first and does not start like the last.

    The button is always rendered, and opens a menu only when there is something to pick: the core read landed, RetroDECK was found, at least one option is bakeable, and there are at least two. The platform's shortcut count is deliberately not a condition: the per-platform core is a setting read when a game is resolved, so it applies to games synced later too, and picking it before the first sync is the ordinary case. In every other case it is the same chip, disabled, with the reason in its title — the ruling the Remove group already follows, and what keeps the header's shape constant across panes. A disabled button is still a focus stop and the wide page's own sheet gives it Steam's focus outline, so a reader walking the header lands on it and is told why.

    Which of those cases also keeps a line under the header is a judgement about what it reports, not about the chip. "Nothing to switch" states — the read in flight, one emulator on the menu — say it in the tooltip alone: a sentence would spend a row of the pane reporting that nothing can be done, which is what the device round asked to remove. States that report a PROBLEM keep their line, because a tooltip is a hover and the Deck's controller cannot perform one: the read failed, RetroDECK was not found, ES-DE lists no emulator at all, nothing on its menu is bakeable, and the fallback is not installed. The first two of those three are the split above and they are checked in that order: an empty menu is the case where RetroDECK's own fallback fails too, so it is answered before the not-bakeable one, and the surviving count branch then speaks only for a menu that really does hold one bakeable option. A switch the backend refuses is reported in the same place, and the header keeps naming the old core, which every shortcut following the platform's pick still launches with. A switch takes the page's busy hold from the moment it is picked until it is over; an accepted one re-bakes the launch command of every bound shortcut, which is why the hold has to cover the whole of it. The chip and the pane's buttons disable, another platform's pane says Working on X, and the acting pane says Switching to <emulator>… in the same status line the outcome lands in — a success takes that line back, a refusal replaces it, and a continuation cancelled by leaving the page takes it back too, because such a switch either committed or never ran and there is no pane left to report to either way.

  • BIOS files — the summary, which this pane words nowhere: frontend/src/utils/biosSummary.ts holds all seven states and answers each in two lengths, and the pane takes both — the short status as the section's coloured note beside BIOS FILES, the sentence under it, with the library's own (d/t RomM library files) ratio behind the sentence in every one of the seven. The ratio was a description line of its own here, and only in the state that said nothing was required; the shared sentence replaced that line and took the ratio with it, while the game page went on appending it to every sentence — one platform, two surfaces, two different amounts said about it. (system_image: "absent" outranks the counts and the decline alike, tested before either inside that module, because the console asks for one of the images and no count can state that; "unsettled" and required_withheld are declined VERDICTS over rows that answered, so neither reaches nothingEstablished — which is now the narrowest decline and decides one extra LINE only, the by-hand route.) Then a table: File, On disk, Contents, and a Download button on every row that is missing and in the RomM library (#164) — never on a folder declaration, whatever its state, because the emulator opens that name as a directory — and a Delete button on every row a download record of ours still holds. That covers a declared folder too, where no record carries the row's name and the button counts the distinct files our records name underneath it (Delete (N)): a folder is never a download, which says nothing about the files already inside one. Same authority as Delete BIOS, described below. Below the table one row of buttons: Download required (N), Download all, Delete BIOS behind a ConfirmModal. All three are always rendered and disable when there is nothing to do, the ruling the Remove group already had: on PS2 all three vanished at once, and a button that disappears is a state the reader has to work out. A disabled DialogButton is still a focus stop, so the row stays walkable.

    Every sentence names the emulator, off the firmware payload's own active_core_label — the label half of the pick those very counts were filtered by, never the core read beside it on the page. An empty required_count is worded "<emulator> marks none of its BIOS files as required" and absent is "<emulator> cannot start this system without a BIOS image", because both are statements about one emulator's declaration while the console's own demand is the separate axis beside them — not_demanded for a console nothing is recorded about as readily as for one shown to start with nothing — so a subjectless sentence stated the count's conclusion as though the console had been asked, and the older absent wording additionally said no BIOS file was in the folder where what was read is that none of the DECLARED images was. Where the pick has no label the sentences name the role instead ("The launching emulator …"), and the short status beside BIOS FILES stays subjectless because it is a heading, with the sentence under it where a name fits. "Emulator" and never "core": what a platform launches with can be a STANDALONE emulator, which is not a core, and the whole answer is keyed on the emulator's identity for that reason.

    The game page's BIOS tab reads the same module and shows the sentence alone, with the same ratio appended in the same words — a third set again, which is why it rides along on both rather than being folded in. The ratio names that set in its own words, because the sentence in front of it counts another one and the numbers cannot say which is which: The one file DuckStation requires is in place (1/20 RomM library files) states three correct numbers over three sets, and the words are the only thing that tells them apart. The pair is the library's inventory for the platform — what it holds, and how many of those the plugin found at their destination (CONTEXT.md → Library inventory) — and the tail names no axis of its own deliberately: the ratio form carries that, and each candidate word for the numerator was worse than none. Two of them are already on the screen under this sentence and stand for something else there — present is the row marks and on disk the column beside them, both the row's own verdict rather than this pair — and the third, downloaded, would read as a claim about who put the file there, which is more than local_count counts: on_server rows whose file is at its destination, the field itself answering presence and nothing more. Neither surface prints the ratio where the library holds nothing for the platform: (0/0 RomM library files) counts a set that does not exist. What stops a surface writing one of these sentences back into itself is frontend/src/utils/biosSummary.test.ts, which reads the components as SOURCE and fails on any phrase the module builds its answers from, with biosHeldRatio.test.ts doing the same over the ratio. Both SWEEP the set they search rather than naming it — every non-test .tsx under frontend/src/bigpicture, via frontend/src/test-utils/componentSources.ts — because naming it is what failed: the lists held two while three surfaces rendered these states, and a surface left off a list cannot be told from one that never drifted. Deriving the set from who imports the module would be worse still, since a surface wording a state for itself is precisely one that does not import it. What the sweep cannot see is a NEW wording invented for one of these seven states; no string search can, so a green run is evidence about copied sentences alone.

    The Platforms list's row tooltip reads the same module too (PlatformsTab.tsx's biosTooltip) and takes the sentence, so hovering a row and opening its pane give one wording rather than two. It was the last one in, and while it was outside the module it was also outside both locks — so it went on saying an emulator "requires none of the files it names" after the other two had moved to naming the declaration, and one platform was described two ways a keypress apart with a green suite. It words three answers itself, and none of them is a BIOS state: a row whose read has not arrived, one whose re-read failed, and one with no payload at all. Those are about the READ, which the module has no input for, and each is taken off firmwareState rather than off the payload being absent — firmware is non-null in the answered state alone, so all three of these share it and a truthiness test cannot tell them apart. Of the module's two lengths it takes the sentence, because a tooltip carries no heading beside it and the short status needs one to mean anything.

    What the two Download buttons and the per-row one are built off is the fetchable set, and none of the three reads the verdict — one predicate, isFetchable in frontend/src/utils/biosFetchable.ts, over on_server && !downloaded && declared_kind !== "directory", and neither bios_level nor required_withheld nor system_image is read anywhere among them. The predicate lives outside this file because the game page's BIOS tab asks it too — there it is one of the answers that earn a row a line at all, since a file no page can fetch, that the launch does not require and that is not there, is nothing that page can act on. What each surface DOES with the answer stays its own: the game page's rule for keeping a row is four further answers wide and belongs to that page, and this one has no such rule. Two further inputs sit beside that filter and are of the same two kinds rather than readiness gates: Download required counts required_by_active, the launching emulator's own declaration, and Download all stops at the library's own finished ratio. Readiness is not an input to any of them, in any of its shapes: what the resolver could establish is the EMULATOR's demand and what is fetchable is what the RomM library holds, and neither answers the other. Gating on the verdict is what took the buttons off PS2, GameCube and PSP when a BIOS answer was first scoped to the emulator that launches — those three launch standalone emulators the resolver holds no card for, so the verdict declines over a library that still holds their files, and the pane then offered nothing to press on exactly the platforms that need one.

    A running download is said by the button that started it. The pressed button — bulk or per-row — becomes a spinner, every other download button on the pane disables, and when it finishes the rows re-read. There is no "Downloaded X" notice any more: a success says itself. A failure still gets words, in the same status line under the section, carrying the backend's own message; for a DOWNLOAD that line is failure-only, and the pressed button itself says Failed in red for two seconds before everything returns. The platform-wide Delete BIOS still writes its result there on success too ("Deleted 3 BIOS file(s)"), which is the one outcome on this pane no row can show; a row's own Delete says it by the row changing. The spinner is keyed on the run's slug as well as the button's identity, so walking to another platform mid-download shows disabled buttons and the "Working on X" line rather than a spinner that belongs elsewhere.

    The per-row Delete is authorised by the download record and nothing else — the row carries deletable_count, which the backend derives from the same records the platform count comes from (_stamp_deletable), and the unlink re-reads the record and takes the path it holds. downloaded is os.path.exists and is equally true of firmware RetroDECK ships: dolphin-emu/Sys/codehandler.bin sits one row above a real download on a GameCube pane, no RomM library can hand it back, and authorising on presence destroyed exactly that file on a real device. All three buttons — the platform's, a file row's and a folder row's — run one removal loop (_delete_recorded_io) under different record predicates, because a second copy of that loop is exactly what the register's BIOS-delete rule warns about.

    On disk holds marks and never text, and it is the only place presence is stated. A cell carries one or two of them.

    Mark 1, on every row, carries two facts at once. The glyph is the verdict — ✓ met, ✗ not met, ? nothing could establish it — read off BiosFileEntry.satisfied and never off presence, because for a folder declaration the two come apart entirely. The colour is the need: strong where the launching core requires the file (green ✓, red ✗), muted where it does not (pale green ✓, grey ✗), keyed on required_by_active so the table and the summary above it cannot mean different things by "required". Two states have no place in that four-way scheme and are not folded into it, and they are not the same state either. A verdict nothing could establish is an amber ? — the glyph channel has nothing to say. A wanted: "unknown" row — no placement in the platform's catalogue, and the launching emulator's reading incomplete (reading_complete_for) — keeps its verdict, which IS established, and goes amber on the colour channel alone: an amber ✓ or ✗. Reading the need axis first would spend the glyph on a need-axis fact and throw the verdict away, on exactly the platform made entirely of such rows. optional and not_needed do share the muted branch: for the core about to launch, neither is a gap.

    A fifth state replaces the muted answer where the row is one of several images any one of which starts the console (BiosFileEntry.system_image_candidate). Such a row is never required_by_active — its core marks every one of them optional, which is all a libretro .info can say about a disjunction — so the four-way scheme drew five grey "missing, not required" marks under a red headline saying the console needs one, and a reader took the grey marks at their word. What is true of the row comes from the PLATFORM's system_image rather than from the row: absent makes each of them a way to fix it (red ✗), held makes the rest genuinely spare (grey ✗), and anything else passes the doubt on (amber ✗). A candidate whose verdict is met is the console's held image and is drawn green — the candidates are a subset of the rows classify_system_image weighs, so it cannot be anything else. The two amber states above are tested FIRST and are not displaced: an unestablished verdict is still ?, and an unestablished need is still amber.

    Mark 2, ⊘ in violet, appears beside mark 1 wherever on_server is false and the declaration is a file — the RomM library does not hold this one. A declared folder is excluded, and not as a special case: no library holds a folder, so the backend stamps every folder row on_server: False unconditionally and the mark would say "your library does not hold this" about something nothing could. That is the sentence biosFileNote already refuses to produce, and the reason the download filter and the download batch refuse those rows too. It is additive and never a replacement: a present file you could not fetch again keeps its green ✓ and gains the ⊘, and a required missing one keeps its red ✗. Folding the two axes into one colour channel is what would collapse required and optional among exactly the rows that cannot be downloaded. It reads the field only for display; the readiness count, the progress ratio and the download affordance each read it their own way, and the invariant register in CLAUDE.md owns that rule.

    A legend under the table names the marks it actually contains, one entry per line — an entry for a state no row is in explains nothing and costs a row, and mark 2 is inside that filter with one line of its own rather than one per verdict it can stand beside. An entry's identity is its sentence, which is also the row's own title: since the console's own demand became a state, glyph plus colour names two different sentences at once (red ✗, green ✓, grey ✗ and amber ✗ each mean two things), so a legend filtered or keyed on the pair would show one of each and hand the other React's duplicate key. The legend is the only one of the three wordings a controller user can reach (the others are title attributes), so it words the amber rows as what they are — nothing could say whether the file is wanted — and never as "nothing asked for it", which is the not_needed claim and a synonym of the grey "missing, not required" two lines below it.

    On a platform nothing could answer for, the rows nothing could be asked about are counted once, in the line under the table that also says where to report the gap; the summary above it states the condition without a number, because the count up there was the same sentence twice on one screen. Counts on this pane are pluralised (1 file, 2 files), never written as file(s).

    Everything a row says in words goes under the row, full width — biosFileNote's note first, then a folder's images — because a 48px cell wraps one sentence across three lines. The one note that does not appear there is the library one ("not in your RomM library" and its missing variant), which mark 2 now carries: the helper flags it as fromLibrary so this surface can drop it without re-deriving the helper's precedence, and the game page's BIOS tab, which has room, still prints it. Notes are rare on a healthy install — over the .info corpus the rows that carry one are the handful RetroDECK supplies itself and PS2's folder declaration. That is not a bound on the vocabulary: biosFileNote's caveat wording ("its location could not be read", "a folder is here, where the emulator opens a file") appears wherever a destination cannot be read, which no corpus predicts.

    A declared FILE's note also carries what became of its bytes — the row's checked, the resolver's own word, carried from the same entry declaration is. Three of its eight values are three different things behind one withheld verdict and the surfaces worded all of them "could not be checked": unrecognised is a file the emulator READ and does not recognise (DuckStation boots such an image and calls it an unknown BIOS), so that sentence was untrue of it; unread is bytes that did not come back, which the sentence fitted; and refused arrives with the verdict already false — the emulator will not open a file of that size at all — so what it needs is the REASON beside a mark that is already red, not a withheld wording. On a stock RetroDECK all three come from DuckStation's standalone route alone, and refused needs a per-region BIOS key naming a file, which RetroDECK leaves unset. Mark 1 stays the verdict and says only that nothing was settled either way; the cause is the note's.

    The file name is printed once. The description under it is not RomM's — _server_files builds no description at all and _wanted_fields overwrites what came in, so what arrives is the core's own firmwareN_desc, or the file name itself for a row no placement covers.

    Only a read declaration's prose is shown at all, which biosFileDescription decides first and both surfaces therefore inherit. The row carries the resolver's own word for how the emulator that supplied the description stated what it wants (declaration, alongside declared_kind), and the two words that can reach a row are two kinds of writing under one field: a libretro .info's is a packager's LABEL for the file, which says what the row's own name does not — (PS1 JP BIOS) on scph5500.bin, a region the name never states — while a packaged card's is atlas explaining the requirement in whole sentences — "a PlayStation BIOS image — the console runs it before any disc, and DuckStation starts nothing without one — found by the search, not named by any setting". That is an essay on a line sized for a label: it broke off mid-sentence in the platform detail's clipped line and filled the row on the game page. The register is read off the declaration and never inferred from the row, because an identity ending in _libretro.so is the resolver's spelling to change and because a row several emulators declare carries the prose of exactly one of them — the first, which is also the one whose declaration the row states. A row that states none shows none either; its description is the file name, which the rules below take out anyway.

    Those rules apply to what is left. Both spell the name into the words, and across the 292 .info files a stock RetroDECK ships (695 declared entries) they do it in three shapes: the description IS the name (35%), the name then prose (47%), or the name with its directory then prose (17%). The rule is to drop a leading token that names this file — as itself or at the end of a path — and keep the rest verbatim, with a first half that strips the name where the description opens with it verbatim, which is the only way a name containing spaces can be seen ("7800 BIOS (U).rom (7800 BIOS)"). Surrounding quotes are stripped before that comparison, which is what reaches the corpus's one folder declaration ("'pcsx2/bios' folder", on a row whose name line already shows that path). Together they fire on 690 of the 695; of the five printed whole, three name a folder the file sits in and two are upstream misspellings of the file. The rule is biosFileDescription in frontend/src/utils/biosFileNote.ts and both surfaces apply it, because a rule applied on one is a row reading two ways: the game page's BIOS tab used to head its rows with the raw description, which put the packager's prose where the file's identity belongs — and heading such a row with the declared file instead prints the name a second time under it on every shape that opens with the name, unless this rule takes it back out. There the row's head is the declared path whole rather than a prefix and a name.

    Where the result GOES is each surface's own, and the two differ because their name lines do. On the game page's BIOS tab the label follows the name on the SAME line, in the packager's own punctuation — scph5500.bin (PS1 JP BIOS) — which is the form it was written in, with our declared path in place of the bare basename. Three parts read left to right there: the name, then what the file IS, then biosFileNote's note behind its em dash, which is how it STANDS. The two marks do the separating themselves — parentheses for an identity, a dash for a state — so the pair does not read as a chain of equals. It is a span of its own inside the name span, muted like the per-core lines rather than coloured like the name (a sibling flex item would take the row's 8px gap where the packager wrote a space), and the block under the row is then what the read found and who wants the file, with nothing in it that is about the file's identity. Under the row is where the label used to be, and against a list of five emulator lines it read as a sixth entry.

    On the platform detail it stays a muted line under the row, clipped to one line (nowrap + ellipsis). That is a column width and not a difference of opinion: that name sits in a ~202px table cell that clips, and it has already given up its folder prefix to the same shortage, so a label hung off it would take the NAME off the screen — the one thing a reader placing a file by hand needs. The reason is stated at both call sites, because "unify the two surfaces" is the obvious-looking change that breaks it. The declared folder goes the other way on both, onto the name line as a muted prefix (dc/ dc_boot.bin), where it belongs to the file's identity — declared_path carries it, because file_name is a basename and local_path is joined under a root the frontend does not know. 207 of the 695 declarations name a subdirectory and their descriptions spell it in only 115, so the description was never a substitute. On the platform detail a row can therefore carry two lines under it — the description first, then biosFileNote's note — and neither is in a cell any more. Contents is answered for a folder declaration only: the count of images it holds (the resolver's verbatim strings are listed full-width under the row, pre-wrap, because the padding in them is what makes a line matchable against the emulator's own picker), or that it holds none, or that nothing could establish its contents. A file row reads an em dash, and that em dash means the question was never asked — the machine-wide reading is deliberately unverified, #1803 is what will ask it, and until then the dash must not come to mean "asked, and nothing found". The section appears whenever the firmware read speaks for the platform, synced or not — there is nothing to say about one it does not cover.

  • Remove — Remove N shortcuts and Delete N save files on one row, the actions the Data Management platform modal used to offer, without Delete BIOS (it is one group up). Red, last, each behind a confirmation, and with no heading over them: both buttons name what they remove and are drawn in red, so a title says nothing they do not. Both buttons are always rendered and disable when there is nothing to delete; neither is ever hidden. Hiding the group on the shortcut count alone strands a platform whose shortcuts were removed and whose saves remain — those saves are then unreachable, and this is the only page that offers them. Only the shortcut removal is gated on a running sync (the remove_platform_shortcuts use case names the sync rule; delete_platform_saves deliberately does not), so the hint under the pair names that button rather than reading as though it covered both. A count still being read is neither a zero nor a failure, and the saves button must not look like either: while it is coming the button is disabled and carries a spinner, which claims nothing — a pressable plain label would invite a press over an unknown set, and a 0 would state an emptiness nobody established. A read that failed is the third case and says so in a line under the pair, because with a spinner above it a silent failure is a spinner that never stops; the button stays pressable there, since a failed count is not evidence that there is nothing to delete.

Six reads feed the tab. Three are list-shaped and run once per page mount: get_platforms (RomM's platforms with ROMs, the list itself), get_firmware_status (which platforms have a BIOS answer to give) and get_registry_platforms (ROMs bound to a Steam shortcut per platform — the shortcut counts, and what "has synced games" means here). Only the first gates the list; the other two fill in beside it, and each says so on the pane when it fails, because for both of them a failure and an answer arrive the same way — as an absence. A failed get_registry_platforms read as zero shortcuts would print 0 in Steam in the header and disable the removal, two claims about a platform nothing was learned about; the counts go to null instead, which is not zero, and a line under the header says the number is missing while the removal stays live (it needs only the slug).

The fourth is get_platform_firmware_status, and it is asked once per platform rather than once per page, because what a platform's BIOS state IS costs a live per-system reading of the machine. Measured on the reference device: the reading itself is 67-350 ms per system and a platform's whole answer 106-486 ms, so a 28-platform library put 4.1 s in front of the first row when one call answered for all of them — where the call that merely names those 28 takes 4.6 ms. The page therefore renders from the cheap reads and the answers arrive underneath it, and four rules hold:

  • The frontend owns the order. It walks its own list top-down and puts whichever row the reader is on next, because that is the pane that is open. The backend answers one platform at a time and builds no ordering of its own.
  • A row waiting for its answer is drawn and worded apart from one nothing could be established for. Both hold no answer, so both would otherwise be the grey dot that means "unknown" — an ANSWER, and the wrong one, over most of the list for the first seconds of every visit. A waiting row draws the dot as an outline and says "Checking…"; the solid grey dot keeps its meaning.
  • Leaving the page stops the work. No further platform is asked for, and an answer already in flight is dropped rather than written — a visit's answers belong to that visit, and a fresh one has re-read them.
  • A BIOS download or delete, and a core change, re-read that platform and no other. The files (or the emulator) changed for one platform; re-reading the library would pay a live reading per platform for it.

Two reads for one platform are ordered by a counter taken when each is issued, and only the newest may write. The core picker is offered while a platform's BIOS answer is still coming — it needs no BIOS data — so a core change really can put a second read in flight for one platform, and the older one lands describing the core that is no longer picked. Its only symptom would be a wrong colour.

A read that failed is worded apart from a platform the overview genuinely has nothing to say about, which is a finished answer. So is a failed re-read: an answer already held is not taken back, the pane goes on showing it, and a line above says it may be out of date — and only that platform's pane says so, because only that platform's read failed. Picking the platform again retries it, which is what the line says; only that and the save count recover without reopening the page.

The last two are per-platform-slug reads issued once per selection and cached for the life of the page. get_system_core_info exists because neither the list reads nor the BIOS read can answer for the focused platform at the moment it is focused: get_platform_core_info is keyed by ROM and layers that ROM's own pin on top, and the per-platform BIOS answer carries the same emulator fields but arrives whenever the walk reaches that platform — which is not when the reader opens its pane, and never at all for a platform the page has nothing to say about. It costs one ES-DE options read and a settings.json lookup, and opens no database transaction. count_platform_saves answers how many save files the platform holds, for the Delete N save files button: nothing else knows the number, because the delete finds its files through the platform's installed ROMs and counts only what it removed, afterwards. It walks that same path without deleting — and like the delete it first follows each ROM's moved save directory, so it can move files, back up a collision and write a ROM's record before it counts. That path is 6N+1 short BEGIN IMMEDIATE transactions for N installed ROMs, not one: 7N+1 where no ROM's directory is recorded yet, 4N+1 with save sync off, when nothing is followed. Counting rule: SqliteUnitOfWork.__enter__ calls during one count_platform_saves call, over the contract harness's real wiring — the real ActiveCoreResolver, no per-game pin — with only the save resolver's reading of the machine faked. The platform's id read opens one (SaveService._installed_rom_ids_on_platform); then, per ROM and under its lock, the live reading opens three — RomInfo.is_content_installed's install row, the save answer's own install row, and ActiveCoreResolver.active_emulator_for_rom's read of the ROM and its install, after which it resolves through get_emulator_options(system) (the heavy read, which globs each option's emulator install through the find rules) and the resolver reads the machine for the answer itself; the follow opens two — the pending-home-migration check and the record's read, plus the record's write at first sight; and find_save_files opens one more for the install row. On a 128-ROM platform that is 769 lock acquisitions. That cost is deliberate and is not the read's to fix: it must walk exactly what the delete walks, or the number offered stops being the number taken. What keeps it out of the way is that it is offloaded off the event loop and asked once per selection, and that a failure — SQLITE_BUSY among them — degrades to a line saying the count could not be read rather than to a wrong number — and that failure forgets the slug, so re-selecting the platform asks again, which is what the line says and is the only failure on this pane that does not need the page reopened. It is asked again after a delete, so the button stops offering saves that are gone.

Collections is list and detail too, and the list holds the kinds, not the collections: kind is where a reader already navigates, so it becomes the list, and a collection is a row in its kind's pane. The layout study the shape was chosen from is collections-layouts.html. From the top, the list column carries:

  • Collections — the standard kind, the Favorites row's collection left out — and Smart collections.
  • Autogenerated, a group heading over two rows, Franchises and IGDB collections.
  • a thin rule;
  • Favorites, a row with its sync switch in it, as a platform row has. Its count is the favorites collection's ROM count.
  • Other users' collections, a row with its switch in it, as Favorites has, and the list column's refusal line under it. Its count is how many of the collections RomM lists are other users', the number alone — its pane says whether they are shown — with a dash where the read failed, and nothing while the read is out or while Tender cannot yet tell whose a collection is. It is the owner scope (CONTEXT.md → Collection owner-scope): on is all, off is own. It is a switch in the list column rather than a segmented control beside the search because it is a sync setting that applies to two of the kinds, and a control shaped like a filter would say otherwise. Its pane has no table: what turning it off does (other users' collections are hidden here and left out of the sync, even ones switched on, and turning it back on brings those choices back), that Tender can tell whose a collection is only once it knows the user's RomM account and until then nothing is hidden, and how many of the collections RomM lists are other users' and whether they are shown or hidden.

Collections, Smart collections and Autogenerated follow RomM's own headings — "Collections", "Smart Collections" and "Autogenerated collections"; Favorites, Franchises and IGDB collections are this page's.

Whose a collection is is get_collections' is_own, and it has three values. true and false are established ownership. null is a standard or smart collection while Tender does not yet know the signed-in user's RomM id: nothing established it either way. The page reads null the way the sync does — the owner toggle hides nothing it cannot attribute, and such a collection is a candidate for the Favorites row — but the Owner column never calls it the reader's own. A virtual collection has no owner and is always true.

The Favorites row is the signed-in user's own favorites collection — a standard one marked favorite whose is_own is true, or null while ownership is not established. Another user's public favorites collection is not a candidate for it: it is an ordinary row under Collections, its Owner cell names that user, and it follows the owner toggle like any other of theirs. Where more than one favorites collection is a candidate, a single switch cannot stand for them, so the row stays, greyed, with "more than one, listed under Collections" in place of the count, and they are ordinary rows of the Collections table. That happens with two marked own, and also before the user's id is known, when another user's public favorites collection is a candidate too. With none at all the row stays, greyed, with a dash. Either way the greyed row's switch is disabled, so the row itself becomes the focus stop and A selects it: its pane, which says why, has to stay reachable.

Two kinds of row share the list (§ List and detail, a row's own selectOnActivate): the four kind rows carry nothing and name true, so each is a focus stop and A selects it; the Favorites row names true only while it is greyed, and otherwise leaves A to its switch; the owner switch's row names nothing, follows the list, and leaves A to its switch. Selecting the owner switch's row enters its pane as selecting a kind does: the search is cleared and the pane's refusal line goes. The Autogenerated heading and the rule over Favorites are slots of their own in the list that hold no stop, so focus never lands on them and they select nothing.

A kind row states "N of M": how many of its collections are switched on, counted over what that kind's table lists under the owner toggle, before any search — with the toggle off, another user's collection that is stored as on is neither listed nor counted. Every one of those counts comes from the one get_collections answer, so no row costs a read, and focus selecting holds nothing back behind a press here. While the answer is out a row states nothing, and where the read failed it states a dash.

The page opens with Collections selected, and its row carries the entry-stop mark, which goes unread here because the page is tabbed (§ Tabs). The first stop of the list column is the Collections row itself, so where Steam takes the first stop, focus lands on the row that is already selected. Only the device shows which stop Steam takes; no test here can. The selection is kept across a switch to Platforms and back.

A kind's pane holds, in order:

  1. the kind's name with a short description;
  2. one sentence, worded for that kind, on what turning one of its collections on does: at the next sync it adds all of that collection's games to Steam, including games on platforms the reader does not sync, and groups them in a Steam collection named after it. The pane does not spell out that name; what it is exactly, and the suffix the Steam Library setting adds, is the user guide's to state;
  3. one line with the fuzzy search and Enable all / Disable all: the field takes the rest of the line, and the two flat buttons are as wide as their labels;
  4. a table drawn with § Tables' shared one, in the compact register the Sync page's tables use too (COMPACT_TABLE_REGISTER) — Collection, Owner, ROMs, In Steam, Sync on Collections and Smart collections, and Collection, ROMs, In Steam, Sync on Franchises and IGDB collections, which have no owner. The Sync cell carries a label-less toggle, so the row is reachable through it and is no focus stop of its own, the case § Tables names for a cell that carries a control. The Sync cell's toggle carries no padding and no focus fill of its own (why, as read from Steam's bundle, is at SYNC_CELL_TOGGLE in CollectionsDetail.tsx); while it holds focus the whole row shows it: a fill across the row in the colour Steam gives a focused Field, and a marker on its left edge in the list's selection colour. Owner reads you where is_own is true, and the owner's RomM user name otherwise, a dash where the listing carried none. In Steam reads the count, and a dash where it is absent, which is unknown rather than zero. Collections that are on sit above those that are off, and the order is frozen while the page is open, as on Platforms. The 50-row render cap stays, with a line under the last row saying how many more there are; in practice only the autogenerated kinds reach it. A kind that lists nothing says so. With the search empty and the owner toggle hiding some of the kind's collections, a second line says how many: "N from other users are hidden while Other users' collections is off"; with a search, or with nothing hidden, there is no such line.

The Favorites pane has no table. While the read is out it shows the spinner, and where it failed the failure, as every pane does; once it has answered, the sentence and the game count where the row stands for a collection, and otherwise only why the row is greyed — the sentence is about turning one on, and a greyed row has none.

In Steam counts how many of a collection's ROMs are already in Steam (CONTEXT.md → Reachable). It costs no RomM request of its own. RomM's collection listings carry each collection's member ROM ids, on all three kinds, and the count is those ids looked up against the rows Tender keeps — the test the sync uses when it files a collection member into a Steam collection, so a member it counts is one turning the collection on files under a shortcut Tender already made. It counts members, not shortcuts: two versions of one game in a collection count twice. It can fall short where Tender does not yet know a member's version group — a member it has never fetched, or a row fetched before version groups were recorded — until a sync fills that in. ROMs minus In Steam is still only roughly how many shortcuts turning the collection on adds, because several new versions of one game become one shortcut. The same listings carry the owner's user name on standard and smart collections, which is what the Owner column shows. get_collections forwards both — in_steam_count on all three kinds, absent when Tender could not read its own record, and owner_username on standard and smart, null where the listing lacks the field — and the member ids do not cross the wire. It costs the four RomM requests get_collections makes — one each for standard and smart, and one for each of the two virtual types the plugin syncs — and one read of Tender's own database after them.

Those two columns are why a kind is the pane and a collection a row. A pane per collection would show the owner and how many of its ROMs are already in Steam, and both fit as a column of the kind's table, so such a pane spends the whole detail on what one row already says.

Enable all / Disable all ask first only when the table lists more than 20 — with a search, when the search leaves more than 20 (CONFIRM_ABOVE in collectionKinds.ts), on both buttons and on every kind; 20 or fewer are written at once, with no dialog. It counts the collections listed, not those whose switch would change. With a search typed the dialog names the collections that match rather than the whole table. When the dialog closes, on OK and on Cancel alike, focus goes back to the button that opened it: Steam's dialog does not hand it back, and the pane's region would otherwise follow focus to wherever it landed. They write exactly the collections the table lists, in one batch (save_collections_sync over their ids): with a search, every collection the search leaves, those past the 50-row render cap included; with none, the whole table. So on Franchises and on IGDB collections they write that type alone, and with Other users' collections off no write reaches a collection known to be another user's — until Tender knows the user's RomM id it cannot tell, and it writes what it lists, as the sync syncs it. Where the Favorites row stands for a collection, the Collections write leaves that one out, since it is not in the table; where the row is greyed because more than one favorites collection is a candidate, those are ordinary rows of the Collections table and the write includes them.

The kinds' names reach the Steam names the by_label naming mode builds (CONTEXT.md → Collection naming mode), the description of the Steam Library setting that turns that mode on, and the user guide: (Smart), (Franchise) and (IGDB Collection), with (Autogenerated) as the fallback for a virtual collection of no known type; a standard collection, favorites included, carries none. The rule, why it is shaped so, and the places the labels are spelled are steam-non-steam-shortcuts.md § Collection naming mode. The keys on the wire and in settings.json stay standard / smart / virtual.

A failed read is answered, and asked again. Where get_collections fails, every pane states the backend's message in place of its table — or a sentence of its own where the failure carried none — with a line saying that leaving the tab and coming back asks again, and the kind rows state a dash. Entering the tab asks again for a read that failed, and for the owner toggle's setting where that read failed; a read that answered is kept, with its frozen order, for as long as the page is open, and a read still out is not asked twice. While the collections read is out the list and the owner toggle are already standing, and each pane shows a spinner where its table goes.

The four writes are a table row's switch, the Favorites switch, Enable all / Disable all, and the owner toggle, and they follow the rule at the top of § Library (Only the latest write speaks). Each is reported where it was made, and each place has a line of its own: a refused owner toggle or Favorites switch in the list column, under the owner toggle; a refused table switch or Enable all / Disable all in the pane, under the search line. The pane's line also goes when another row is selected. Selecting another row also clears the search, since a search is about the row it was typed on.

One more thing keeps an answer off the pane's line. A pane answer sets or clears it only while no other row has been selected since its write was issued, a round trip back to the same row included. So nothing lands on a pane entered since.

Settings

Wide, untabbed, list and detail: the sections on the left, the focused section on the right. Six sections: five hold what the narrow page stacked in eight — Registered Devices sits under Save Sync, SteamGridDB joins the other external service under Connections, and the save-sort migration the narrow page carried is gone, because each game now follows its own save directory the next time the plugin touches its saves — and Updates is new.

Section Holds
Connections the services the plugin talks to: RomM (URL, account, Sign out, Allow insecure SSL) and SteamGridDB (the API key), one group each, titled by service. Home of every sign-in.
Save Sync the toggle, device, before-launch and after-exit, default slot, history limit, Sync all now; then the registered devices as a table
Controller Steam Input mode, Apply to all shortcuts, the input_driver fix. Home of the fix.
Steam Library preferred region, collection games in platform groups, collection types in Steam names — the narrow page's Library section, renamed because a Library page now exists: the page is the RomM side (what is synced), the section is the Steam side (which version, in which groups, under which name)
Updates installed and available version, "Development build — install updates with the installer." where this is a run from a checkout, the daily-check switch, Check now and what it found. Home of the update notice.
Advanced log level

The registered devices are the one thing on the page with more than two facts per row, so they are a table — Device, Client, Last seen — drawn with § Tables' shared one at the pane's default register. The layout study it was chosen from is device-list-layouts.html.

RetroAchievements is not here, and Connections is still its home. The plugin has no RetroAchievements account and no sign-in for it — building one is #1627, which also left the badge's home open between the game page, the retired System page and global settings. The section holds the two services that exist rather than a placeholder for the one that does not.

The section rows carry no control of their own, so the list is built with selectOnActivate: the activate handler that adds is what makes a row a focus stop, and without it the list is neither walkable nor scrollable. Every read-only row in a detail pane — a sign-in result, each row of the registered-devices table, the row naming this device — is a stop for the same reason, since a pane scrolls only by moving focus and a group with no stop in it cannot be reached at all. Two kinds of line are not, and for two different reasons. Content the region reveals on its own — above the pane's first stop or below its last (ScrollRegion's revealEdge) — needs none, which is why the migration card carries no handler. And the devices table's column header carries none under the Tables rule: the names accompany the rows below them and a stop there would be a step that leads nowhere.

Settings' value inputs — RomM URL, custom headers, account, the SteamGridDB API key, default slot — each open a modal, because nothing on the page has to be seen while one is typed (Text input).

Two failures the narrow page carried are fixed here (#1020). A refused URL no longer sticks: the pending edit exists to carry a value across the remount that closing the modal can cause, so it is cleared whichever way the attempt ends, and the next open of the page shows the URL that was saved rather than the one that was rejected. And Apply to all shortcuts refuses a second press while a run is in flight — the guard is in the handler rather than only on the button, because a disabled control still reports a press on the device, and the button says which state it is in.

Data Management

Wide, list and detail — and the rows are not the operations. A row names a population, something this device holds, and states its numbers; the pane says what that population is, gives its numbers in full, and offers what can be done with it. Six rows, flat and ungrouped: Tender's shortcuts, Installed ROMs, Grid images, Other non-Steam games, Gone from RomM, Recovery bundles. The per-platform actions have left for Library › Platforms, and the platform modal with them.

That the rows are populations is a finding, not a preference. Arranging the five operations as a menu needs a name for the group they fall into, and every grouping of these five needs a category that exists only on screen. A category nobody can name is not a category: the rows were buttons, and a button says nothing about the device. The question a reader arrives with — what has this program put here, how much disk does it hold, what is left over — a menu of verbs cannot answer at all, while an inventory answers it before anything is pressed. The layout study the shape was chosen from is data-management-layouts.html. The list needs no headings for the same reason Settings needs none: six rows that each name a thing are their own order.

Row What it says on arrival What the pane offers
Tender's shortcuts total_shortcuts — the bound shortcuts Main counts every visit Remove all shortcuts
Installed ROMs one count per install — two kept versions count twice Uninstall all ROM files
Grid images scan until asked — then how many are orphaned Remove the orphaned images
Other non-Steam games Steam's own store less this plugin's entries the whitelist, the removal, the RetroDECK guard
Gone from RomM scan until asked — the server round trip Review, which opens the dialog below
Recovery bundles how many are sealed and what they take the bundles one by one, and nothing to press

Every figure is reading, failed or answered, and its row's count slot shows which: a spinner while it is being read, a dash where the read failed, the number once it is answered. The two scan rows add a fourth state before those three, not asked, shown as scan. A pane never borrows one state's words for another: under a read still in flight it says Reading… (a scan's own button says it is scanning and cannot be pressed again), and under a failed one it says what could not be read and how to try again — reopening the page for a figure read on arrival, pressing the scan button again for a scan.

A number that costs a round trip or a backend scan sits behind a press. Focus selects on this layout, so a reading that rode on the selection would fire under every row the stick passes — a local scan of the grid directory for one row, a RomM round trip for the other. Grid images and Gone from RomM therefore read scan until they are asked, and keep the answer until something makes it wrong, because a stale number is worse than no number. A finished cleanup puts Gone from RomM back to scan. Grid images goes back to scan after any removal of shortcuts from this page — Tender's shortcuts once the backend has accepted, other non-Steam games, and a finished Gone from RomM cleanup — because each can leave images the scan did not count: a Tender shortcut the plugin had no record of, or one whose removal report did not complete (that report is what deletes a bound shortcut's images), a foreign entry whose art nothing here deletes, or a cleanup run without recovery, which leaves its shortcuts' images in place. A grid-image removal re-derives its candidates rather than taking the scan's, and answers how many it found beside how many it removed; the row reads 0 only when the two match, since then nothing orphaned at the moment of the removal is left. Fewer removed than found means files that would not delete, and the row goes back to scan with the pane's status line saying how many; so does an answer missing either count or removing more than it found, or no answer at all. A refused removal deleted nothing, so the scanned count stands. The grid removal holds the page's busy state like the other removals, so no removal button — its own included — can start another while it runs.

The whitelist's search sits inline, above the list it filters, which the pane expands under Configure whitelist — the search kind of text input, not the value kind that opens a modal (Text input).

Two rows would overlap if either were read naively, and the one that gives way is the foreign one. Tender's shortcuts are themselves non-Steam shortcuts, so a row counting Steam's store whole would report this plugin's own library a second time, under a heading saying Other — and its removal would take that library with it, since the whitelist protects by NAME and a synced library carries game names. So the foreign row is Steam's store less what this plugin created, told apart by what a shortcut launches rather than by what it is called, and the foreign row never removes ours: row 1 is where they go wholesale, the Gone-from-RomM cleanup takes the individual vanished ones, and a platform's own removal in Library takes a platform's. That reading is a per-shortcut sweep rather than a field: it takes time, so the row shows a spinner until it lands, and what it cannot establish it never offers — neither a store it could not read at all, nor an entry whose own reading did not arrive. The pane says which of the two happened. An unproven entry left alone is a row that under-reports; an unproven entry offered is a library deleted.

The size is the server's figure and never a walk of the disk. Rom.fs_size_bytes is what RomM reported for a ROM (#1395), summed over the installed rows, so the page opens with a number instead of measuring for one — and it is written with a ≈ for two reasons: an unpacked archive, a patch beside the original or extras in the same folder are not the size the server named — a multi-file game is, which is why it is not on that list — and the field is NULL for a row that predates its migration or belongs to a wholesale-skipped platform nobody has re-applied, so the sum understates rather than fails. The cleanup goes on measuring the disk for itself, because its free-space line has to hold for a bundle it is about to write rather than for a figure a server once reported.

The removed-games review stays a dialog, and the reason is geometry rather than inertia. A dialog may take up to 720 × 406 on the Deck — maxWidth: 720 against maxHeight: 76vh of the 534 px viewport, which is the right denominator because the modal renders into findSP()'s document rather than inside the 454 px panel. A detail pane is 530 px wide (806 px of content, less the 264 px list and the frame's 12 px gap — layout/Columns.tsx) and its height is what an untabbed page's body is left with after whatever chrome sits above the page and the frame's Back-and-title row — about 350 to 365 px on the Deck, depending on whether the 454 px container is taken as the scroller's own height or the dev window's numbers above are carried across, which put the scroller some 14 px below the top of its view. Both of the pane's numbers are derived from what is written here rather than measured on the device for this section, and the margin is wide enough that the derivation would have to be badly wrong to change the answer: moving the review into the pane would shrink it in both axes. Its rules do not change; they live in removed-game-cleanup.md.

The dialog reads top to bottom as the options, the run's bar, then the candidates. The four options stand in two columns under the intro, each a short label with its full meaning in the line under it, and the acknowledgement a run without a recovery bundle asks for appears under the bundle's own toggle. The bar holds the recovery estimate against the free space at the target, Refresh free space, Load more while a page is still unloaded and the rule that every page is loaded before Confirm, Cancel and Confirm Cleanup, and while a run is going its progress and Stop Cleanup, then its result and the details region, and the line saying why Confirm would refuse a press. The bar sits above the table, not pinned under it as the layout study drew it: focus follows element order rather than the picture, so a bar after the rows is one press per row away however it is drawn — the Sync page keeps its buttons above its tables for the same reason. Load more is in the bar for that reason too: every row is a stop, and Confirm refuses until every page is loaded, so after the table it would be a table's length from the press it unblocks; the rows it adds land below it. The candidates are a table (Game, Platform, Verdict, Installed, Keep a copy). A verdict says what is known now and never the run's outcome — Gone from RomM for a candidate, Still on RomM for a version listed only because a whole-game removal could take it, each with one of 4 where the game has more than one version. A version still on RomM is removed too if the run's check finds every version of its game gone, so neither a verdict nor the section's heading (Other versions of these games — still on RomM) says kept: either would promise more than the preview knows, and a heading is read before the line that qualifies it. The full sentence behind a verdict is the cell's title, which is a mouse's way to it only: a tooltip is a hover the controller cannot perform, so on the controller the intro and the other versions' section line say the same. For the same reason what tells two versions apart is on screen: under each row a line names the version — its ROM id, and its file name where that says more than the game's name — because two gone versions of one game otherwise draw as identical rows, each with a toggle of its own. The other versions are a section of the same table under a heading whose line states the sentence for all of them. Keep a copy is the per-row toggle that puts a version's installed ROM content in the recovery bundle: an installed row carries it and it is that row's focus stop, since a second stop on the row would be a dead step in front of it; a row with nothing installed carries a dash and is a stop itself, so the table can be walked. A row's warnings — a field shortened for the wire, the preview's own warning, and the ROM file that goes without a backup — are lines under it, inside the row. The dialog is revealed at both edges: focus reaching the first option scrolls it to its top, so the intro comes back, and focus reaching the last row scrolls it to its end, so that row's own lines and the note after the table are not left under the view. Steam scrolls only far enough to reveal the focused control, which is why both are needed. The suite pins that each scroll is asked for; whether it lands where it should is device-only.

Recovery bundles are listed and nothing here removes them. The row states how many are sealed and what they cost, so the disk they take stops being invisible, and the pane lists them under that total as a table — Game, Sealed, Size — newest first. Game and day (UTC) are read off the folder's name alone — no file's contents and no seal are read for them — so the game is spelled the way the folder spells it (Shenmue-II), and a folder not in the shape Tender writes — one renamed by hand, or one ending .durability-uncertain because its seal could not be confirmed — is listed under its own name with no day, after every dated one. Each row is a focus stop with nothing to press, because the pane scrolls only by focus. Deleting one is the reader's own business in a file manager until a cut gives that action a home. A row that shows something and offers nothing is still a row this page owes, because the page's claim is what this device holds.

Downloads

Narrow and unchanged: the active rows with progress, their Pause / Resume / Cancel as rows below the list, the finished rows, Clear Completed. Reached through View All on Main while the queue is not empty; an empty Downloads page has no menu entry.

One home per action

Action Today Target
Start a sync Sync Sync; Main starts none, but keeps Cancel
Review and apply a preview Sync, as a table Sync, as a table
Force Full Sync, Skip preview Sync Sync
Restart Steam now (session budget) Sync Sync; Main shows the notice
Sync a platform on or off Library Library › Platforms
Choose the emulator core Library › Platforms Library › Platforms
Download BIOS files Library › Platforms Library › Platforms
Delete BIOS files Library › Platforms Library › Platforms
Remove one platform's shortcuts Library › Platforms Library › Platforms
Delete one platform's save files Library › Platforms Library › Platforms
Fix the RetroArch input_driver Settings › Controller Settings › Controller; Main shows the notice
Pause or cancel a download Downloads Downloads
Clean up removed RomM games Data Management, modal Data Management › Gone from RomM, reviewed in a dialog
Check for a newer release Settings › Updates Settings › Updates; Main shows the notice

Sequence

The pages land in this order under #1808, each with the open work that already sits in its file:

  1. The wide frame (#1813) — the two levers and their clearing paths, the Back row, tabs, list and detail, the measured height. No page uses it yet; it is the one piece that rests on Steam internals and is reviewed on its own.
  2. Library (#1815) — Platforms and Collections; System and the Data Management platform modal retire. Carries #164, #1803's column, #1016's frontend half, #1020's collections fix. Second on purpose: the emu-atlas work under #1735 renders its BIOS and core changes into the new Platforms detail instead of the retired System page. Landed: Platforms (#1836), then Collections (#1833) in three code PRs — the backend's in-Steam count and owner name, the page as list and detail over the kinds, and the Steam-name suffixes that follow the kinds' names.
  3. Sync (#1814) — the new page, Main's reduction to status rows and one conditional slot, Skip preview persisted, the run-list read, the per-platform preview breakdown. Carries #886's presentation half. Landed, in two PRs: the backend half, then the page and Main's reduction. The import choice (#1364) is the one thing the page leaves space for.
  4. Settings (#1816) — the sections, Steam Library, the homes for the input_driver fix and the save-sort migration with their notices on Main. Carries #1020's URL and double-press fixes. Landed, in one PR; RetroAchievements has no sign-in for Connections to hold until #1627.
  5. Data Management (#1817) — the page becomes an inventory of what this device holds, the removed-games review stays a dialog and is redrawn, and recovery bundles become visible. After Library, which removes the platform modal.

Main has no issue of its own: each change to Main lands with the page that gives it a home. Downloads has none either. i18n (#1524) comes after the rebuild, and the pages avoid copy that breaks when German or French expands it; the store screenshots (#830) are taken after.

Design record

  • #1808 — the epic; its sub-issues are the sequence above.
  • #1809 — the design issue this page answers.
  • The layout study the Platforms tab was chosen from: library-layouts.html — the three layouts weighed for this page (list and detail, detail with a header, one wide table) at the Deck's real size, each with what it costs. The second is what shipped. Superseded on two points by the device rounds: the list row's BIOS ratio (dropped — the row is dot, name, toggle) and the core picker's full-width button (now an icon in the header line). The study is a record of a choice, not a description of the page.
  • The layout study Data Management's shape was chosen from: data-management-layouts.html — four page shapes rather than four arrangements of the same one: a menu of the five operations, an inventory of populations, one table with no menu, and no page at all (each operation moved to the page that owns its object). The second is what this page describes. It also carries the settled part every shape shared — the review as a dialog — and a table of which numbers exist today, which is what ruled the disk walk out; the study leaves the size as counts-or-a-scan, and the server's own figure replaced it afterwards. Like the studies below it is a record of a choice, not a description of the page.
  • The layout study the Collections tab's shape was chosen from: collections-layouts.html — today's tab drawn at the new width, then three layouts at the Deck's real size: one wide table under a strip of filters, a collection per pane (the Platforms shape), and the kinds as the list with each kind's collections as a table in its pane. The third is what this page describes, and the section above says why the second lost. Its closing list settled the owner toggle's wording, the kind names, the In Steam and Owner columns, the order and #1020's fix; the owner switch's row and when Enable all asks first are not in it. It draws In Steam as all where every ROM is already in Steam; the page prints the count. Like the studies below it is a record of a choice, not a description of the page.
  • The layout study Main's navigation was chosen from: main-layouts.html — four layouts at the panel's real 348 px (status as the card, the menu as the card, menu first, and the chosen one), each drawn quiet and with a preview waiting; a closing Heute section shows Main as it stood when the study was drawn, and its own mid-run board is captioned as the same in every draft. The last layout is what shipped: the menu complete and always in the same place, the status rows inert, and one conditional slot for the two things that have to announce themselves. It is the only one of the four drawn mid-run, and it is drawn so twice: that middle pair weighs keeping Cancel Sync on Main against dropping it, and Cancel stayed. Like the studies below it is a record of a choice, not a description of the page.
  • The layout study the Sync page was chosen from: sync-layouts.html — three layouts at the Deck's real size (a table beside a controls column, one column, list and detail), each with what it costs. The first is what shipped, and its second board settled the run view: one bar for the whole run, one row per planned unit, one bar for the unit being worked. Superseded on two points: its note 3 leaves the Force Full Sync confirmation an open decision and shows the button with none — the shipped page puts it behind a ConfirmModal that states what it forgets; and it draws both of the left column's button rows under their tables, where the shipped page puts them above, because on a controller a button is reached by walking focus onto it one table row at a time. Like the Platforms study, it is a record of a choice rather than a description of the page.
  • The layout study the registered-devices table was chosen from: device-list-layouts.html — three layouts for that one block at the Deck's measured width, everything else on the page held identical so the comparison is about the block alone: today's folded rows, a three-column table with a header, and a two-column middle keeping the Steam Field shape with Last seen right-aligned. The table is what shipped, on the axis the Deck is short of — one row per device instead of two, and eight devices costing nine rows rather than sixteen — and the platform and the shortened id are dropped with it. It records its own objection, and half of it has since been answered: the table is no longer an implementation of its own — it is § Tables' shared one, the same component the BIOS files and the preview draw. What still stands is what the study actually says: it remains the only content on that pane with column headers, and that ends when the rows around it are written against the pane primitives too, which is not done. Like the studies above it is a record of a choice, not a description of the page.
  • The static prototype the decisions were made on: qam-prototype.html, a single self-contained page kept in docs/assets/. Every page at device size with numbered notes; its example data is invented, and it reflects the decisions as of this page's first version — the save-sort notice and its Settings home it draws no longer exist, and the device-list study above draws the same notice. Redrawn to the Deck's real 854 × 534 CSS px — Steam's 80 px top bar over a 454 px panel — once the device round had measured them.
  • ADR-0029 — why the panel widens through Steam's Friends expansion and not a full-screen route, and what that rests on.