Skip to content

QAM panel

The Quick Access Menu panel is Tender'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; one of Tender's pages 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 (GLOSSARY.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 GLOSSARY.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, CleanupList.tsx, cleanupPlan.ts Data Management — the inventory rows, their panes and the page's state; the cleanup dialog, its list and the prediction of what a run would do
frontend/src/bigpicture/DownloadQueue.tsx Downloads
frontend/src/bigpicture/library/ The Library tabs: usePlatformsPage, PlatformsTab, PlatformDetail; useCollectionsPage, collectionKinds, CollectionsTab, CollectionsDetail
frontend/src/bigpicture/bios/ The BIOS part a platform's pane and a game's BIOS tab both draw: BiosFiles (table, marks, legend, buttons), useBiosActions (the presses), useBiosFocus
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 on a swap), 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 Tender'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 fallback 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. Decky's decky marker is read for one thing only, to place Tender's entry before Decky's entries (next paragraph), never as a sign that Decky is there.

Where the entry sits is the user's choice, and it is not ours to set once. The setting is Settings › Interface › Place Tender above Decky Loader (qam_entry_position). On, "above_decky" and the default, the handler puts the entry directly before the first decky-marked entry in the array — the mark Decky's own guard counts by — so every entry Decky pushed stays below Tender's. Off, "below_decky", it puts the entry at the end of the array. Where no entry carries the mark, the entry goes last whatever the setting says. 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 places the entry again each time it runs. Moving an entry is invisible to Decky's guard, which counts marked entries and never asks where they sit.

The handler reads the setting without waiting for the backend. It runs inside Steam's render, so it reads frontend/src/utils/qamEntryPositionStore.ts synchronously: the panel fills that store from get_settings when it loads, and the Settings page writes it once the backend has taken a change. Until the load answers, the default places the entry. The value has no home but settings.json — no copy in local storage, nothing carried in the injected expression — and a change reaches the strip at the menu's next render pass, because nothing forces one. On the device that is the next time the menu opens: switched while the menu was open, the entry did not move, and it stood in its new place once the menu was opened again.

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 JS context. 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's update dot reads stores that are module state of the window the panel's code runs in, and subscribes to them from the tree through useSyncExternalStore — 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 at rest, 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, or a <style>, a class a stylesheet could reach, or an inline animation or transition — with the update dot drawn at rest — because the cost is invisible to every other check here. The one motion it allows is the update dot's fade (below), which runs once and ends with the dot gone. It can see nothing else: a rAF loop, or a stylesheet elsewhere that targets the glyph's elements by tag, id or attribute, would pass it.

The update dot is the one thing on the glyph that reads state: a filled circle in the top-right corner, over the arc's end, in the "is available" card's blue (UPDATE_AVAILABLE_COLOR), drawn while that card would show on Main and its release was not yet seen — updateDotVersion over the card's own answer, availableCardVersion in utils/updateAvailableView.ts, through useUpdateDot in utils/updateDot.ts, which every dot draws through, so none of them can disagree with the card (§ Notices and homes). At rest it does not move either. When its release is seen it plays its one fade: it grows to 2.2 times its size about its own centre and fades out over 450 ms, ease-out, then is taken out. That is one CSS transition of transform and opacity, set on the dot only for the fade, so a dot at rest carries none. The fade is inline style on the element the glyph renders and its end a timer of the window the panel's code runs in, so nothing is taken from, or bound to, the menu's window. The strip has no error boundary (§ The boundary), so a store state the answer cannot be worked out from draws no dot rather than throwing; with every store as it starts — the fallback page's case — there is none. Its size and place on the strip, and how the fade looks there, are device questions; nothing in the suite can see them.

Two things about the glyph 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 (frontend/src/shared/), the boundary it shares with Tender's section on a game page. The boundary only catches; what it draws in the panel's place is the card the Quick Access entry hands it (frontend/src/qam/PanelFailureCard.tsx), 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 panel 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 the panel's 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 panel'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), 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). 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 behind Tender's entry and behind any other that renders the panel.

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 Tender. MenuStore's SetQuickAccessTab, which OpenQuickAccessMenu goes through, clears it (SetQAMFriendsChatExpanded(false)) on every QAM tab change away from Friends, which is a second net under Tender's own ActiveTab observer; and the Friends tab's list (FriendsListAndChatsSteamDeck) 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 Steam's steamui/sp.js, where the receiver is also visible: OnMessage on the FriendsUI store sets m_bQamFriendsExpanded from exactly the two messages Tender 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 panel 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 (its TabContentsScroll class, steamui/css/sp.css), 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. So does text between two stops of the same card: an emulator source card's folder and health lines (Settings › Emulator sources) sit between its arrows above and its switch below, and the region brings them into view on the way from one to the other — a stop of their own would only draw Steam's focus highlight over the text. So, deliberately, does a line above the first stop of a ScrollRegion: Settings › Tender folder's status line, which names the place in use, is no stop of its own, because a stop there would only draw Steam's grey band over a line with nothing to press; the detail column's ScrollRegion reveals its top when focus reaches its first stop, here the first tile row (below). 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 eight 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.

A stranded panel shows no page. While the backend refuses the panel as stranded, the router renders the stranded card in place of whichever page is mounted, Main included. It decides that at the panel's root, QAMPanel in index.tsx, above the page switch, so a page added later is covered without a change of its own; currentPage is kept, and the re-check on opening Quick Access stays mounted beside the card. Why, and what the card says: loading-the-panel.md.

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 two of them name a Settings SECTION rather than the page — 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 two (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> (steamui/sp.js, the TabContents Focusable), 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.

Entry focus is placed on a page swap and never when the panel appears. Steam mounts the panel as soon as the Quick Access strip highlights Tender's Quick Access entry, so focus placed on that mount would take the reader off the strip on the way past — Steam's own tabs and Decky's keep focus there until Right or A. So nothing places focus on the page the panel appears with: not the router, which would otherwise land on the stranded-panel card as well as on a page, not the wide-page frame's timer, and not Steam's tabbed page (autoFocusContents requests focus as the content mounts, so the frame passes it only on a swap). The router tells a page which case it is in through EntryFocusOnMountContext (frontend/src/utils/entryFocus.ts), false until the first swap. A remount of the panel is the panel appearing again and places nothing either.

Entering from the strip lands by Steam's own entry preference. The router's Focusable around every page but Main, and Main's own root, carry navEntryPreferPosition={PREFER_MARKED_CHILD} — Steam's PREFERRED_CHILD, under which entering a node searches its descendants breadth first for the first one marked preferredFocus (FindFocusableDescendant in Steam's sp.js) and, finding none, takes Steam's default entry: the child it last had focus in, the first child on a fresh node. Main marks the menu's Sync entry, through a Focusable carrier around its ButtonItem, which takes no such prop; the cards Main shows instead of itself — the version error, the blocked migration — mark nothing. A wide page marks its body, so entry lands in the body rather than on the Back chip, and the body searches in turn: for the selected row on a list-and-detail page, which ListDetail marks, and otherwise wherever Steam's default entry into the body goes. On a tabbed page — Library — the search finds Steam's own active tab first: Steam marks it preferredFocus itself, and it sits shallower than any row of ours, so entering Library left open lands on the active tab rather than on a selected row. The declarations that serve a swap (ENTRY_STOP_ATTR) and the preference that serves entry from the strip mark the same stops on Main and on a list's selected row, and nothing holds the two equal.

The preference holds only until focus first enters the panel. Steam's search for the marked node runs ahead of the child a node remembers, so a preference left in place would decide every later entry too: focus coming back from a closed dialog would land on the mark rather than on the button the dialog was opened from, and Down from a wide page's Back row on the selected row rather than where the reader last stood. So the router drops it — every navEntryPreferPosition and preferredFocus above — on the first focus event inside the panel's root, read off that node's own event rather than a document (the panel's code and its nodes live in two windows), and hands the answer down through EntryPreferenceContext (frontend/src/utils/entryFocus.ts). After that, Steam's remembered child decides as it did before. A new appearance of the panel — a remount — marks the stops again.

All of this is read from Steam's code rather than seen on the device. Where Steam's Quick Access does not honour the preference, entry lands where Steam decides, and the panel still takes no focus as it appears.

Building blocks

Tabs

Steam's tabbed page, switched with L1/R1, for a handful of peer views of one page. Wide pages only: at 300 px the bumper glyphs overlap the labels. In the panel only Library has tabs; outside it, Tender's section on a game page is the same component (§ The game page's tabs).

On a page opened by a swap, 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 (steamui/sp.js: the GamepadTabbedPage's onFocusWithin drives its tab row's showGlyphs). Entering a tabbed page the panel appeared with lands on the active tab instead ("Pages"), which is within the tabbed page too. Back stays reachable by moving up.

Entry focus belongs to the frame, on every wide page opened by a swap. 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 Main's notices, and before the row was declared a jump to any section but the first mounted it and then had it overwritten about 50 ms later, landing 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: a swap leaves the placing to Steam's tabbed page and the frame places none, and entering from the strip lands on Steam's active tab, "Pages"). Library › Collections opens on its first row, Collections, and declares it anyway — but it is tabbed: wherever a swap opens the page, 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 panel'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.

The game page's tabs

Tender's section on a RomM game's page (frontend-bundles.md) is not part of the panel, but its tabs are this same component: GAME INFO, ACHIEVEMENTS, SAVES, BIOS and ARTWORK are Steam's tabbed page (bigpicture/GamePageTabs.tsx), in that order (buildPanelTabs, bigpicture/panelTabContent.tsx). So L1 and R1, the wrap-around, the bumper glyphs, a tab switching as soon as focus lands on it, and each tab keeping where focus stood are Steam's. ACHIEVEMENTS is handed to it only for a game RetroAchievements knows (an ra_id), so a tab that is not shown is not stepped onto either. SAVES is handed to it with save sync on or off: the setting chooses its pane, the slots or this device's save files (save-file-sync-architecture.md), and switching it while the tab is open switches the pane. Steam's own game page steps its own tabs with L1 and R1 the same way; on a RomM game Tender's section takes that panel's place, so nothing of Steam's is left handling the bumpers there (#2421 D2). SAVES carries a warning mark beside its title (Steam's renderTabAddon) for a downloaded game whose saves cannot sync, while save sync is on (save-file-sync-architecture.md).

Height and scrolling are Steam's game page's. The tabbed page has no height of its own — its tab row and its content are positioned absolutely (steamui/css/sp.css, GamepadTabbedPage) — and Steam's game page passes canBeHeaderBackground: "on-outer-scroll" (steamui/sp.js, the app details' tabbed page), which Steam's stylesheet turns into one screen less Steam's header; once the page has scrolled the tabbed page up under that header, it reaches up behind it and its tab row takes a blurred backdrop (IsUnderHeader). Tender passes the same, so a tab's pane scrolls inside that box rather than the page growing with it. Focus coming into the tabs from outside them scrolls the game page so they stand STEAM_HEADER_PADDING_PX (40 px, Steam's headerPadding) below its top, and focus coming to the play row scrolls it back (scrollToTop on the row) — as Steam's own page does around its tabbed page. That scroll is Tender's because Steam's lives in the overview panel Tender's section replaces, not in the tabbed page.

Once the tabs stand at the top, moving focus inside them scrolls only inside them. Two things would otherwise move the game page:

  • A pane's own focus scroll. Tender's rows centre themselves in the nearest scroller that overflows (scrollFocusedToCenter, utils/scrollHelpers.ts). Where a pane's rows all fit, Steam's per-tab scroller does not overflow, and the nearest one that does is the game page around the tabs. The tabs carry SCROLL_BOUNDARY_CLASS, past which that walk does not go, so such a row is not scrolled at all.
  • Steam's scroll to a focus put back after a tab switch. Steam slides the incoming pane in from the side (translateX(±40%), steamui/css/sp.css) and puts focus back in it before it has arrived. Its own focus scroll hands that element to the browser's scrollIntoView (steamui/sp.js), which scrolls every scroller around it sideways to reach it, the game page's included — that one cannot be scrolled sideways by hand, but a background image of Steam's inside it is wider than the page, so a call can scroll it. The tabs declare Steam's scrollIntoViewType NoTransform, under which Steam brings a focused element into view by its laid-out position instead, with the slide not counted. Steam's own game page passes its tabbed page none and goes through the same code; whether it shows the same shift was not measured.

B steps back as on Steam's own game page (#2421 D5): in a pane it puts focus on the tab row, on the tab row it puts focus on the play row, and only on the play row does it leave the page. The first step is Steam's tabbed page's own — the content pane's handler for B (§ Pages), which the Library page turns off with cancelSkipTabHeader and the game page leaves on. The second is onCancelFromTabHeader, which the tabbed page binds as its own cancel handler, so B reaches it from the tab row; Steam's game page puts focus on its Play button there, and Tender's puts it on the play row's first stop, its Play button — the row registers its element with the scope (bigpicture/tabStepping.tsx). Focus landing on the play row scrolls the page back to the top, as moving there by the d-pad does. Where no play row is registered the handler answers false, and B goes on as it would from the play row. Nothing on the play row takes B, so there it reaches Steam's page. Entry focus is not placed in a pane (autoFocusContents: false, as Steam's game page passes), so the page still opens on the play row.

L1 and R1 from the play row are Tender's own. Steam handles the bumpers on its tab row and its content and stops them there; the play row is outside the tabbed page, and Steam's own play bar handles no bumper. So the play row carries a handler that steps the shown tabs the same way, going round at either end, and the tabs register how they step while they are shown (bigpicture/tabStepping.tsx, the same shape as L2 and R2's emulatorStepping.tsx). L2 and R2 reach the BIOS tab's emulator selector from the tab row and from every pane through one handler around the tabbed page, since Steam's tab row and content pass the triggers on (Library › BIOS, "L2 and R2 step the same way").

A pane keeps what it holds because the panel holds it. Steam mounts only the active tab's pane, keyed by tab id, so a switch unmounts the pane it leaves. The BIOS tab's own state — the emulator shown, a press under way and the line it left — is useBiosTab's, and the achievements' load, made once until the ROM changes, is useAchievements's; the panel calls both. SAVES reads the panel's state already, and ARTWORK's slots are held in utils/artworkSlotsStore.ts and read again on every opening. Where focus stood is Steam's to put back: its tabbed page saves the pane's navigation state per tab id and restores it a moment after the switch, which finds a row only if the pane draws it at once — a pane that came back on a spinner would put focus nowhere.

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 steamui/css/sp.css under a hashed name Steam's build generates and nothing keeps stable; the module's two class names, in steamui/sp.js, are how to find it), 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.

Focus groups

A Focusable holding focus stops or other focus groups is a focus group, and its flow-children prop names the way Steam's gamepad navigation moves between them. Steam's parser knows six values: column, column-reverse, row, row-reverse, grid and geometric. row and row-reverse move left and right, column and column-reverse up and down, grid and geometric both ways; a press the group does not move on goes to the group around it.

Any other value is dropped: the parser logs a failed console assertion, Unhandled flow-children: <value>, and answers no layout — as it does for an absent or empty prop, which it drops without the assertion. A group with no layout takes one from its CSS each time it is asked, and none at all while it holds fewer than two focus stops or groups. Under display: flex the direction decides: row is a row, or a grid where it wraps with flex-wrap: wrap (wrap-reverse stays a row), row-reverse a reversed row, column a column and column-reverse a reversed column. display: grid is a grid, or geometric where it names template areas. Anything else, inline-flex and inline-grid included, is a column — unless its first element child floats (left a row, right a reversed row) or is inline or inline-block (a grid). So horizontal, vertical or right steers the way it reads only while the group's CSS points the same way, and turns with the CSS with no sign of it but that assertion in Steam's console.

This is read in steamui/sp.js, not measured on the device: the parser is the switch that ends in the Unhandled flow-children assertion, and the fallback is the navigation node's GetLayout, which hands the element to a helper that reads its computed style. Module numbers are Steam's bundler output and nothing keeps them stable; the assertion's text and the method's name are how to find them again. The ESLint rule tender/flow-children-value (frontend/eslint.config.js) refuses any other value in a .ts or .tsx file under frontend/src/, and a value that is not a string literal.

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 and the 10 px of the platform page's small print, 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 and the smaller sub-heading inside a section, 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. The removed-games review's list wraps its names instead, because what it says matters before anything is removed and a controller shows no tooltip (Data Management, "The removed-games review").

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.

Waiting

Every state in which the panel waits on work shows the throbber — the spinning ring Play shows while it prepares a start — left of the label that says what is under way; the label keeps its words. One module draws it for the panel and the game page in Big Picture, frontend/src/shared/Throbber.tsx: ThrobberLabel beside a label, which leaves the label a text child of its control so that a button's text and accessible name stay the label's alone, and Throbber by itself where a place lays it out — a loading row, a count slot, a picker's grid. Two places draw the romm-throbber span themselves, both on the game page, where the play row's sheet is present: Play's own throbbers, and the line under an emulator menu entry while whether that emulator can start the game is still being read (frontend/src/utils/emulatorMenu.tsx).

The desktop client's disc and version selector, PLAY and RESOLVE CONFLICT (dev build only) show the same ring while a version switch is under way, without that class: frontend/src/desktop/gameview/SvgThrobber.tsx turns an SVG with SMIL, because the class's keyframes would be a stylesheet on Steam's page outside the restoration ledger (desktop-dom-architecture.md).

The throbber puts its rules into the document it is drawn in (Throbber.tsx, with frontend/src/utils/throbberStyle.ts), because the panel's code runs in a different document from the ones it draws into — the Quick Access view's, Steam's game page's. Play's own throbbers also carry Steam's appActionButtonClasses.Throbber; the shared one leaves that class out, because it draws nothing outside Steam's own play button: in Steam's sp.css on the device, every rule for appActionButtonClasses.Throbber is scoped under the play button's two classes (measured 2026-10-30).

A line that says both what is under way and how it ended — a platform's status line, the cleanup dialog's — carries the throbber only while it says the first. Two waits that have no label of their own show it too: the pressed button of the save conflict dialog while it resolves, and the chosen tile of the SteamGridDB game picker while its game is applied. While a game is being stopped it stands in place of the chevron beside Resume — that button still opens the menu — and beside the menu item's Stopping….

Where it does not stand:

  • Beside a moving bar of the same operation — an update's Installing…, a cleanup's stages, a download's bytes. Where only numbers count up (Removing n of m…, Uninstalling n/m) it does, because a count can stand still on a large item.
  • On a platform's status line once its shortcut removal counts ("Removing X shortcuts…"): the count line under it (Removing n of m…) takes that run's throbber over — one per operation, on the line that counts. Until the first shortcut is removed, and for the whole run where there is none to remove, the count line is not there and the status line carries it.
  • On Data Management's pane lines while a removal runs: the list header above them spins and names the operation.
  • On a BIOS row whose file is still waiting its turn to be identified: only the row whose file is being asked about shows it, and the others keep their plain "Checking…".
  • On the Steam Input status line while Apply runs: the button carries the wait, and the line holds only what the run answered.
  • On the "Working on X" line of a platform pane whose platform is not the busy one: the throbber stays on the pane whose run it is (Library).
  • In the desktop client's save conflict dialog (dev build only): Throbber's spin would need a stylesheet on Steam's page outside the restoration ledger, as the selector's paragraph above says; no shipped bundle carries that surface, and the SMIL ring has not been put there.
  • In a tooltip — the platform list's "Checking this platform's BIOS files…", the core picker's "Reading the emulators for this platform…" — and on a short settings write — an emulator source's switch, Tender's folder.

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 Tender stays a card without a jump, with Dismiss where the condition has a sensible end. The two notices about Tender's folder are the exception: forgetting what is recorded there, and going back to the folder it is recorded under, are actions on the card itself — beside the unreachable notice's jump to Settings › Tender folder — since no section holds them; neither notice can be dismissed.

Condition On Main Home
Tender's folder cannot be reached while something is recorded under it the folder and the reason, Nothing has been removed., Choose another place, Forget what is recorded there; no Dismiss Settings › Tender folder, where another place is chosen
Something is recorded under another folder than the settings name both folders, Nothing has been removed., Use that folder, Forget what is recorded there; no Dismiss none — the card is the whole of it
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
An emulator source reports a health finding a warning card per finding, worded per code, no action none — the fix is outside Tender; Settings › Emulator sources states the same sentence on the source's card
No emulator source is detected warning card, no action none — the fix is outside Tender; Settings › Emulator sources says the same
A switched-on source is one Tender cannot start games through a smaller card per such source, without the warning sign, no action none — Settings › Emulator sources states it on the source's card
Steam lacks parts Tender uses Steam has changed, what does not work, a request to report; no action none — the fix is outside Tender; the missing names are in the backend log
Tender's Quick Access entry could not be added none — Main cannot be reached; a toast at start-up, once none — the fix is outside Tender
Sync paused on the session budget text, Open Sync Sync, which holds Restart Steam now and Resume
An update was rolled back, or refused by its pre-install check both versions, where the reason is, Open Updates, Dismiss — and a toast, once Settings › Updates, which states the same fact whether or not the card was dismissed, and shows what the installer said
An update's installer stopped without updating both versions, the installer's journal, Open Updates, Dismiss — and a toast, once Settings › Updates, where the attempt stands as failed with Try again for the rest of that run, and shows what the installer said
An install from Settings failed or was cancelled none — a toast, once Settings › Updates, where the attempt stands as failed with Try again
A newer Tender release is out both versions, Open Updates, Dismiss — a toast, once per version, and dots on the way to its home Settings › Updates, which states both versions and holds the install, the check's switch and Check now
Tender was updated, or went back the version, Dismiss — and a toast, once none — the card is the whole of it

The emulator source notices are Tender's wording, per finding code, never the resolver's message text (utils/emulatorSourceWording.ts is the one home of those sentences and of the sources' names): a notice per finding of every switched-on source, except content-tree-unwired, which concerns nothing Tender does, and a marker-invalid on the version key alone, which leaves every folder read; both are stated only on the source's card under Settings › Emulator sources, as is every finding of a switched-off source. One code is Tender's own, roots-assumed, naming the roots the resolver only assumed in one notice per source (domain/source_roots.py); the backend leaves it out where a marker-* or not-set-up finding already explains them, and leaves out a missing-root finding about such a root (#2294 D6, D7, D8). A code with no sentence yet, or a known code whose finding lacks a fact its sentence needs, reads "Problem with source: code". Where the answering source gives no emulator list, the platform page, the emulator menu and a game's BIOS tab say why from the answer's reason — no source detected, every source switched off, a broken ES-DE systems file, a RetroDECK that has not been set up yet, EmuDeck's list that cannot be read yet, a platform the source has no system for (no_platform_system), a platform whose systems are all switched off there (platform_system_off), a platform whose system is not known because RomM, which gives its ids, cannot be reached (server_unreachable) or refused the read (any other of RomM's failure reasons, worded as one), or any other refusal — and never "no emulator". Source is switched on in Settings › Emulator sources, but Tender cannot start games through it yet. stands on Main once for every switched-on source Tender cannot start games through, whether or not it is the one that answers, and is the one source notice drawn smaller and without the warning sign: it states a fact about Tender rather than something wrong. The source's card under Settings › Emulator sources says Tender cannot start games through source yet., switched on or off, and a switched-off source says it only there. Where such a source answers, the platform page, the emulator menu and a game's BIOS tab keep saying why there is no emulator list — today no such source gives one. Showing Tender cannot start games through source yet. there beside a list such a source does give is #2222's work; no code does it today.

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 row for Steam lacking parts Tender uses is the one condition read from the start-up checks' reports rather than from a backend answer or an event, and it is the one home of everything both checks missed that the panel survives (#2455 D3): the searches into Steam's module registry, settled before anything mounts, and the Steam internals, asked once Steam reports its services initialised — so it can appear after Main has mounted, and Main subscribes to it (utils/startupMissNotice.ts). It names what does not work, by the short labels both lists share (STEAM_FEATURES, boot/steamInternals.ts), each once — "Not working: starting a game, setting artwork." — and asks for a report; a miss whose absence nothing the reader sees depends on (a diagnostic one) still raises it, without a list: "Everything still works; please report this." (#2455 D4). The names themselves go to the backend log only. Lookups whose absence costs the panel never reach it: the fallback page stands in the panel's place instead. Tender's own notifications are one of the losses it can name, and a miss of what they are raised through has no other home: syncs and downloads run, and most results a toast would have announced are on their page as well, but not all of them — known cases, not a complete list: a sync that fails before its work queue is built, which writes no run, so only the Sync page's status line and Main's transient line say it, each only if it is open when the sync fails (§ Sync); a removed-game cleanup that the game page's version picker cannot prepare for one version, which it answers with a toast alone; and a play-button download that is refused or fails before it is queued, which never reaches the download queue.

The one start-up miss this notice cannot carry is Tender's Quick Access entry's own search: without it neither the panel, nor the fallback page in its place, nor Main can be reached, so the panel factory raises one toast at start-up through Tender's own toaster (#2509 D10), once Steam can show it, and none where one of the three searches a toast is raised through (NOTIFICATION_LOOKUPS) missed too.

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 and the rolled-back notice are the other two 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. Their button row and the playtime notice's sit in Steam's Field with its children below, because the announcement card's Dismiss is a ButtonItem, which is such a row: the space between card and buttons is then Steam's on all four of these cards. The update notice's 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 install, the check's switch and Check now, and to a run from a checkout it shows a line naming this a development build in place of the install. The card's condition is availableCardVersion (utils/updateAvailableView.ts): available on the backend's answer — a newer release with its tarball, a valid digest and its checksum file attached, not the dismissed version, whatever the check's switch says — unless a failed update to that release takes the card's place (below). The switch governs only what the program asks GitHub by itself, so a release Check now found is announced and offered to install with it off too. The answer is fetched at panel load by a detached call nothing awaits (the store's fetchUpdateNotice says why), rewritten by Dismiss, the switch and Check now, and replaced by the notice the backend pushes (update_notice) when its own check while it runs finds a different answer, so the card and its home follow without a reload.

The card has companions that follow it, all off the one answer availableCardVersion the card itself renders from: three dots in the card's blue that mark the way to its home — on Tender's glyph in the Quick Access strip (The glyph), beside Settings on Main, and beside Updates in the Settings list — and a toast, Tender X is available. Settings › Updates to install it., in Tender's name, which does nothing when tapped (utils/updateAvailableToast.ts). The backend owes the toast once per version, across every start — toast_owed on the notice; when it holds, and what records a version as told, is UpdateCheckService notes — and the panel acknowledges it once raised. Beyond toast_owed and the card's own answer, the panel holds it back in five cases:

  • Until the reads made at panel load have answered. The notice only has to settle; what the last update did, a stopped attempt and the install's state have to have succeeded, so a failure record for the same version that answers last still suppresses it rather than being overtaken. Where one of those three failed, this JavaScript context raises none at all and the toast stays owed for the next load: what was not read might be what holds it back.
  • While an update is being installed, from the press of Install until the attempt has ended — before its first frame too, while the press waits for its answer and once the backend accepted it. An attempt under way when the JavaScript context was replaced is known at load from the install's state read (updateInstallStore.ts's seedUpdateInstallAttempt), not only from its next frame. Once an attempt failed, it is raised where nothing else holds it back.
  • While Steam's notification lookups are missing (notificationsUnavailable), since a toast raised then is never seen and its acknowledgement would take it away for good.
  • While the switch is off as the store holds it, since toast_owed is the backend's answer as last read.
  • Until Steam can show it, as the other update toasts wait (utils/steamReadyForToasts.ts); a running game does not hold it back. Once Steam can, it is asked again: where the card was dismissed, the switch went off, Install was pressed, the release was seen or a newer release arrived meanwhile, it is neither raised nor acknowledged, and a later change can ask for it again.

It is asked again whenever the notice, the outcome, a stopped attempt or the install attempt changes, so a pushed update_notice or the end of an attempt raises it without a reload. A push and a read of the same version in one JavaScript context raise one toast between them.

The dots show while the card would, the switch off included, until the release is seen: Settings › Updates on screen for a second without a break (SEEN_AFTER_MS, useSeenAfterDwell in utils/updateDot.ts), with the Quick Access menu open on Tender's tab — reached from the list, or opened on it by the card's Open Updates — records the card's release through mark_update_available_seen. Moving through the list past Updates does not count: focus selects, so Updates is on screen while focus passes it, and leaving it, closing the menu or choosing another of its tabs before the second is up cancels the wait; coming back to Updates, to the menu or to Tender's tab starts it over. Which tab is the active one is the answer useWideQamPanel already works out from inside the page's tree; it publishes it to utils/owningQamTab.ts for the Settings page above it, and nothing is bound to the menu's window to get it. Seen is per version and kept across restarts (UpdateCheckService notes), so a newer release brings the dots back. It takes every dot for that version, and the toast with them, and leaves the card, which goes only by Dismiss or the install — and a Dismiss takes the dots too, since they follow the card. A dot that goes because its release was seen plays the one fade The glyph describes, each dot on screen at that moment playing its own; every other way a dot goes, it simply goes. The two in the panel are drawn by bigpicture/UpdateDot.tsx: 9 px across, about 8 px past the end of the word and centred on its height, positioned absolutely so they take no room of their own and the label does not move when a dot comes or goes. How they look there is a device question.

The rolled-back notice says Update to X failed — you are still on Y. over a line naming where the reason is. For a rollback that is Tender's log, backend.log, which both versions write to, so what the new version logged before it was stopped is there; or the journal (journalctl --user -u romm-tender), for a new version that failed before it could write to the log (UPDATE_FAILURE_REASON in utils/updateOutcomeStore.ts). The installer's own output says no more than that the new version did not answer in time, and points at the same two places. A record the installer's pre-install check left (kind check) takes the same notice with its own line instead: The new version did not start, so nothing was changed. and where the reason is — the installer's output, in its journal (journalctl --user -u romm-tender-update) or the terminal it was run in, since that version never ran as the service and wrote nothing to backend.log (UPDATE_CHECK_FAILURE_REASON, chosen by updateFailureReason); the failure block under Updates, whose title already says nothing was changed, names the same place without that clause (UPDATE_CHECK_FAILURE_NOTE), and the button under it shows that output (§ Settings). A record of a kind this version does not know — a later installer's, kind unknown on the wire — takes the notice with no cause named, only where the installer's output is (UPDATE_UNKNOWN_FAILURE_REASON). The notice stands while the installer's record does — which the backend reports only while the running version is the one the record names as still running (UpdateOutcomeService notes) — and its Dismiss is per record: it records the record's rolled_back_at (update_failure_dismissed_at), so the next record the installer writes raises it again, and the record going away — the next update whose new version answers removes it — takes it down too. Its home states the same failure in the card's amber frame (cardFrame, bigpicture/UpdateCard.tsx), whether or not it was dismissed (§ Settings). It takes the place of the update notice for the version that update tried: after a rollback that version is still newer than the running one, and the two cards side by side would call a release available and failed at once. So for as long as the record stands, that version raises no "is available" card, dismissed or not; a newer release raises one as usual (failureTakesThePlaceOf in utils/updateOutcomeStore.ts). The backend's answer (get_update_outcome) is read at panel load by a detached call, like the update notice's. A refusal by the pre-install check that the running backend sees while an install from Settings is under way is also pushed (update_failure_recorded, taken by takePushedUpdateFailure), so the notice is up at once rather than at the next panel load; it outranks a read still in flight, as the other pushes do.

The notice that an update's installer stopped without updating is the rolled-back notice's sibling for the one failure the installer cannot report: it stopped Tender, then gave up and started the same version again. It uses the same sentence (updateDidNotGoThrough) and, below it, the Updates section's own line for that failure, pointing at the installer's journal. Its answer is its own read (get_stopped_update_attempt, utils/stoppedUpdateStore.ts), made at panel load beside the other two, and a push (update_attempt_stopped) for the one the backend could judge only after the panel loaded, once the installer's unit had ended. Dismiss removes the backend's record of the attempt, so the next start does not raise it again; a press of Install the backend accepts takes the card down too, since the new attempt ended that record, and with the card goes its hold on the "is available" card. It takes the place of the update notice for the version it tried, as the rolled-back notice does, but only while it stands — once dismissed, that version's "is available" card comes back (stoppedAttemptTakesThePlaceOf). Its home is Settings › Updates, where the attempt stands as failed with Try again for the rest of that run.

The update announcement is the one condition shown twice over, as a toast and as a card, both from that same read. The toast says Tender updated to X after an update, Tender is back on X after a return to an earlier release. The backend owes it once per process (toast_owed) and the panel acknowledges it after raising it, so a Steam restart that reloads the panel does not raise it again. Like the failure toast below, it waits until Steam can show it; past a deadline it is raised anyway, and either way it is acknowledged only once raised. What it waits for, how long at most, and why: utils/steamReadyForToasts.ts. The card is there because a toast raised as Steam comes up can be gone before it is seen: in windowed Big Picture on the desktop the toast's popup is laid out again with the rebuilt window after the JavaScript context reloads, and may show for a second or two or not at all. It says Tender was updated to X. or Tender is back on X. and carries Dismiss and no jump, since the announcement has no home. It stands until Dismiss, which the backend records, so a reloaded panel shows it again until then; raising the toast does not take it down, and dismissing it before the toast was raised does not stop a toast already waiting. Both live in the backend process's memory (UpdateOutcomeService notes): a backend restart on the same version owes neither, so the card is gone then too. The store is filled before the toast's wait, so neither card is held back by it.

A failed update is announced by a toast too, once, in Tender's name like every other toast: Update to X failed. You are still on Y. Settings › Updates shows why. for the installer's record and for an installer an earlier start found stopped, and for an attempt of this backend's the attempt's own reason — Update to X failed. The download failed. or Update to X failed. The installer stopped without updating., or Update to X was cancelled. A game was started. Nothing was changed. for the two aborts titled cancelled (utils/failedUpdateToast.ts). Every one of them is owed by the backend until the panel acknowledges it, and raised only while it is owed: the record per rolled_back_at and the stopped attempt per started_at, both kept in kv_config because they stand across starts (failure_toast_owed on get_update_outcome, toast_owed on the stopped attempt; UpdateOutcomeService notes), and an attempt of this backend's per press, in memory, since the attempt lives in this process only (get_update_attempt_toast; UpdateInstallService notes). The panel asks for an attempt's toast at load and again whenever an update_install_progress frame turns failed (toastOwedAttempt), so a frame sent while no panel was loaded does not take its toast with it, and a reloaded panel finds it acknowledged. A record whose card was dismissed owes none, and dismissing a stopped attempt removes it. A refusal by the pre-install check owes no attempt's toast: its record is pushed (update_failure_recorded) just before the failed frame and raises the toast — once, the record's. A read something overtook raises no record's toast; the push raises its own. A push and a read of the same failure in one JavaScript context raise one toast between them. Where Steam has no notification store the cards and Settings › Updates are the only word of it, as for every toast.

The three update notices stack in one order: the announcement card, then the rolled-back card, then the "is available" card — what this start runs on, then an update that did not go through, then a release still to be had. The announcement and the rolled-back card can stand together — a record the installer did not remove stands again once the version moves back onto the one it restored — and then the announcement names the running version, which for a standing record is the one it restored, while the rolled-back card names the version the update tried.

Five of the conditions above carry no Dismiss anywhere — an emulator source's health finding, no emulator source, a source Tender cannot start games through, the missing notifications 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 emulator source cards (a card per finding of every switched-on source, then the one for no source found, or one per switched-on source Tender cannot start games through) 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 five notices that carry a button (a run paused on the session budget, the version this start runs on, an update that was rolled back, an update whose installer stopped without updating, 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.

Entering Main lands on the menu's Sync entry rather than on the first row of the status block — the rows below state and do nothing, so landing on one spends the reader's first press. From the Quick Access strip it lands there through Steam's entry preference ("Pages"), and on a swap back from another page through Main's declaration of its entry stop ("Building blocks → Tabs"). 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 re-stamp, of a platform or a collection, 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 (Library sync: the run) 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. The one end that page does say is an apply run's failure, on its status line, because, on that page, the only other word of its reason is a toast (§ Sync).

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 endpoint 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.

An apply run that stops at stage error is reported whichever page is open. It emits no sync_complete, whether it failed before its work queue was built or part-way through, so the toast that announces a finished run never comes; its one word is a sync_progress frame at error. Two places read it for this, both keyed on the frame's runKind and both worded by utils/syncFailed.ts — "Sync failed — " and the frame's message, never the prefix twice. The sync_progress listener in index.tsx, which lives as long as the panel, raises it as a toast, once per run id. If the Sync page is open when the frame arrives, it puts the same sentence on its status line under the start button, the line a failed preview writes; it stays until the next press of that button clears it, and it is held in the page's own state, so it is gone once the page is left. A preview's failure gets neither: its own answer writes that line, and a preview is asked for only from this page — one that fails after the reader has left is said only by Main's transient line, and only while Main is open. Main's transient line says an apply run's failure as well, while Main is open. A failure before the work queue is built writes no sync run, so the run list and Last sync do not change.

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 JS-context rebuild, 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 hold nothing the press would remove; 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). What the press removes is read from the two fields that describe it, has_completion_stamp and resumable_games, and the run history is not consulted: the clear keeps it on purpose, so it says nothing about whether there is anything left to clear. 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), each the name-part of a Steam collection — the key the run finalizer builds (Collection naming mode). The same counts split per platform ride the summary as platform_breakdown — one row per platform holding at least one non-zero count, keyed by the platform's fs_slug (#2452 D1), ordered by 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 one the preview's platform-collection diff files the platform by, so the row carries the name the apply gives the platform's Steam collection: RomM's listed name for every platform the run's listing names, synced or not, else the name the backend recorded, and the bare fs_slug where neither answers (#2564). A name carried on a fetched entry is not read. 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 endpoints 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 — one row per RomM platform, by its fs_slug, so two platforms that share a slug are two rows, each named as its Steam collection is (#2452 D1, D2). The rows stand 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 met, red not met, grey not established or no level, and amber only where the resolver's verdict is open because of the disc's region alone; the per-file rows on the platform detail and the game page hard-code the same colours, and never use amber for a row). 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 or an update is being installed, 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 a two-line header instead of a Sync section. The first line is the platform's name alone, with the core picker's icon button right-aligned beside it. Under it, in the platform page's 10 px small print and muted, stand up to three lines (#2195 D25): the emulator in effect (platform-facts); one line per enabled source whose user chose an emulator inside ES-DE (platform-frontend-choices, "Gearsystem chosen in ES-DE", "RetroDECK: Gearsystem chosen in ES-DE" where more than one source is listed), absent where no source has such a choice; and N versions on RomM · M versions in Steam (platform-counts). Each part of the counts line keeps itself on one line and carries its · in front, so the line wraps only between parts and never cuts one. The system a platform is in is not named while one is found; it shows only in the line that says why there is none or why it is switched off (core-emulator-selection.md). Those lines, the notes under the BIOS heading and the lines under each BIOS file's name share one size, NOTE_FONT (10 px), below the 11 px SECONDARY_FONT the other panes' secondary lines keep: on the Deck the three read as large as the rows' file names at 11 px, and set apart they looked like three different sizes. The BIOS table's rows and its column names are set at 12 px, the heading's size, and the marks in the On-disk cell at 12 px too: on the Deck 11 px and 10 px are no difference at all, so a file name one pixel above the description under it did not read as a name, and column names in the secondary size read as notes rather than as a table head. Two pixels is the gap that shows. The row buttons keep their own 11 px label, so focus and press sizes are unchanged.

Both counts on the counts line are ROM files, and the line says so: it reads "N versions on RomM · M versions in Steam" ("1 version" on either half) — "version" is the glossary's word for one ROM, so it reads apart from the shortcut count on the Remove button. M is not claimed to be a part of N, because it need not be one (below). 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's listings no longer return (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 versions on RomM · 8 versions 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 (GLOSSARY.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 the ones the cleanup's own discovery lists — domain/fetch_generation.py::gone_from_romm_ids answers for both, so the count leaves out every version the Gone from RomM list shows: every row not carrying the fetch generation the platform's completion stamp recorded, a row carrying none included, and every row carrying a gone-from-RomM mark (roms.gone_from_romm, Removed-game cleanup). A version the user hid in RomM is left out too, though RomM still serves it: no listing returns it, so from the first apply of its platform after the hide its row no longer carries the generation the stamp recorded. The Gone from RomM list leaves it out only because its scan asks RomM for the hidden list (#2502 D5); this read asks RomM nothing (D10). Where no usable stamp exists — none, one with no generation, or one recording an empty fetch — the stamp names nothing and only the marked rows are left out, 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, except one carrying a gone-from-RomM mark. 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 versions on RomM · 3 versions in Steam beside Remove 4 shortcuts. Three shapes reach it — a bound version deleted on RomM, which the stale removal keeps bound; a bound version the user hid beside a visible one, which it keeps bound too; 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. The second heals at the platform's next commit. The first lasts until the user switches the game to another version or removes the gone one with this cleanup. The second lasts until the user unhides it in RomM or switches the game to another version. The direction is a conservative under-count, which is why it is recorded rather than guarded.

The BIOS ratio is not on the second 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 beside the name, 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 part opening the line under 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. active_core_label names the platform's pick, else Tender's default (the entry the first enabled source ships first, core-emulator-selection.md), whatever its state — a default that is not installed is named like any other. The clause is red where the games cannot start (frontend/src/utils/emulatorClause.ts):

    • The emulator cannot start them — start says cannot_start: it is not installed. The clause names it in red and the line under it says why (frontend/src/utils/emulatorStart.ts, the same sentence a game's page and a refused start use); the chip stays open, and the line points at the emulator menu, only where another entry can be pinned. The platform page asks no start command, since a platform has no game to ask it with: whether the resolver gives one is said on a game's page and at its start (#2221 D20).
    • No default — the entry the source ships first could not be established installed, so it is never the default by itself (#2195 D9). The clause reads no default in red, the line asks for a choice with the cause where one is known, and the chip stays open where an entry can be pinned.
    • No options at all — get_emulator_options answers available: true with an empty list for a system whose catalogue entry lists no command. A platform with no switched-on system in the answering source is not this case: its list is not available, with the reason no_platform_system or platform_system_off (core-emulator-selection.md). The clause reads no emulator in red and the line says the games will not launch, because they will not.

    Under them stand, where they apply, the pick whose name no longer exists in its source ("Your choice 'label' no longer exists in source; the default 'default' is used", or "there is no default to use instead", D5). Each source's own default is not repeated on the page; the emulator menu marks it (D4). Where Tender's default comes from a source games do not start through, none of the starting source's own lines stands under it — no refusal, no vanished pick, no "lists no emulator", no red — and the clause names that default in the default's colour, also where the starting source lists no emulator (D24).

    The button is always rendered, and opens a menu only when there is something to pick: the core read landed, RetroDECK was found, and there are at least two options, one of them a choosable entry other than the one in effect — every entry is choosable but one the resolver states not installed (#2195 D9, D23) — or nothing is the default and an option can be pinned. 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. A core read the backend refused says why in place of the generic failure: invalid_value can only be the platform's slug, since the page asks with a listed platform's own folder, and reads "RomM lists this platform without a console slug, so Tender cannot tell which emulators it has. Check the platform in RomM."; any other refusal reads as the endpoint's own message.

    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, the answering source gives no emulator list (worded by why, § Notices and homes), ES-DE lists no emulator at all, nothing on its menu is choosable, the default is not installed, and the default or the platform's own pick is one whose installation could not be established. ES-DE listing no emulator and nothing on the menu being choosable are the split above, and they are checked in that order: an empty menu is the case where no game of the platform starts at all, so it is answered before the not-choosable one, and the surviving count branch then speaks only for a menu that really does hold one choosable option. A switch the backend refuses is reported in the same place, and the header keeps naming the old core, which every game following the platform's pick still starts with. A switch takes the page's busy hold from the moment it is picked until it is over; an accepted one brings the platform's BIOS links up to date and reads the platform again, 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. The pick is the row's own: it is stored under the platform's fs_slug, so of two platforms that share a slug, a pick on one leaves the other's choice as it is, and the core_changed event it sends reaches only the game pages of that platform (#2452 D7, D16). An open page of a game with no fs_slug recorded is not read again when the pick of the one platform kept for its slug changes: it shows that pick once it is reopened, and its next start uses it either way (D20).

  • BIOS files — the summary, which this pane words nowhere: frontend/src/utils/biosSummary.ts holds every state and a one-of group's, 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 them. 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. (A one-of group the launching emulator states is worded first — an unmet one always, any other only where no plain required file is missing or withheld beside it — "<emulator> has a BIOS image for USA only — Japan and Europe discs will not start", with "1 / 3 regions · USA only" as its status — off one_of_groups alone, so a group on any console reads the same way. A group's status counts REGIONS: the first number is the regions an option in place serves (covered), so a region nobody checked is in the second number and never the first; where the emulator also requires plain files the status is "X / Y required · A / B regions", the plain files alone in X / Y, and the same plain counts feed the sentence; an unknown group reads "Readiness unknown" where it words the line — except a group open only for regions it carries no option for (unstated), which reads "<emulator> has a BIOS image for <covered>" and keeps its region count ("1 / 3 regions · regions not checked") — and where a missing plain file words it instead the status is that count followed by "regions not checked" ("0 / 1 required · regions not checked") — never "0 / N regions", which is an unmet group's. Wherever no game region narrows the group — the platform page, and the page of a game with no clear region, where the platform's verdict stands — every group sentence is followed, where unstated is non-empty, by "for <regions> discs <emulator> states no BIOS image, so whether they start could not be established" (a game of such a region is named by its own sentence); region names and a file's place in a group are frontend/src/utils/biosGroup.ts's. The colour is the resolver's verdict and never the module's: it words what the verdict means, ahead of everything: first an emulator the resolver does not cover, then a BIOS nobody chose in its settings (PCSX2's [Filenames] BIOS, read off the setting the resolver names, #2190 D6), ahead of the rest because that empty setting is why PCSX2's card reads as not read — then an emulator nobody read (under a level that is unknown only: a verdict the resolver stated over an emulator read as not read is worded as that verdict) and a required declaration the resolver would not follow, then — under a red verdict — the console's own need for an image where none of the emulator's images is in place (console_needs_image, said of the console by RomM's platform name, the row's name, or "This system" where none is held) — with the search-found-nothing note behind it where the resolver also states the search reached no image; an emulator that names no image gets the need without a count, and a note whose search states no count of candidates (DuckStation's) is worded by its folder alone — then what a search by content found (that note where no console sentence took it, then content_search), then the group and the counts — worded by how the emulator finds its firmware (locating: "opens by name" for by-name, "lists as required" for unestablished, "requires" for one that also searches by content); nothingEstablished keys off emulator_read alone and decides one extra LINE only, the by-hand route, which is left out under the no-BIOS-chosen sentence (saysNoBiosChosen, #2190 D29).) Then the emulator selector (EmulatorSelector, over emulator_tabs on the payload, #2190 D15), one entry per emulator the resolver answered for, the launching emulator first and shown. Its top row reads "L2 ◀ ● <emulator> 1 / 5 ▶ R2": the dot is that emulator's own requirements_met, and the arrows step to the previous and next entry, wrapping around. Nothing is written after the name: its colour says what a suffix would — the accent (SELECTION_ACCENT) where the emulator launches, red (RED) where it is not installed, red winning where both hold — in the top row and in the carousel alike. The dot and the name alone stand on the row's centre, the axis the carousel centres its shown entry on. Steam's own controller glyphs for L2 and R2 stand beside ◀ and ▶ (ControllerGlyph, drawn for the controller that is connected), with a small key box in their place where that probe misses; no footer legend entry is added. L2 and R2 step the same way (#2190 D31): on the Library's Platforms page wherever focus is on the page, the platform list included, and on a game's page wherever focus is in Tender's section — the play row, the tab row and the tab's content — while the BIOS tab is open. As Steam's shipped client code shows (steamui/sp.js), it dispatches a gamepad button as a DOM event on the focused element, which bubbles up through its ancestors until a handler stops it (its client wires onButtonDown as a listener and hands an unstopped event to its root), so the handler sits on the nodes each scope is made of (ListDetail's columns; the play row and the one node around the game page's tabbed page, which Steam's tab row and content pass the triggers on to, inside EmulatorStepScope in patches/rommOverviewBlock.tsx), and the selector registers how it steps while it is shown (bigpicture/emulatorStepping.tsx). Where no selector is shown — fewer than two emulators, the BIOS tab closed — the triggers go on to Steam untouched, and no other button is taken: L1 and R1 stay the page's tabs'. Steam's own parts of a game page are outside Tender's section and are not reached. Under the top row a carousel holds every entry with its dot, in NOTE_FONT: the shown one in the middle and outlined, the others on either side in stepping order and wrapping around (utils/emulatorCarousel.ts), its edges faded out. Every emulator stands in it once, however few there are (#2190 D33), installed or not: one that is not installed (installed: false, the picker's own reading) stays, its name in red. The launching emulator's entry is outlined in the accent its name takes, which keeps it known where its name is red, and the shown one otherwise in light grey. A launching emulator nothing identifies has no entry at all rather than one under a made-up name; the selector then opens on the first emulator it lists. Every entry is a focus stop of its own and is chosen by pressing it, never by focus landing on it; a step slides the carousel, because each entry is one element moved to its place rather than rendered there. Steam's own Dropdown is not used, because in Game Mode it opens its menu over the whole screen. Every entry is read from the answer it is rendered with, by its identity, so the re-read after a download recolours the dot together with the header. One identity is one entry however many labels the emulator list gives it, named by all of them joined " / " in the list's own order — "LRPS2 / PCSX2" for pcsx2_libretro.so — whichever is the pick, built where the entries are (emulator_tabs in services/firmware/emulator_answers.py, #2190 D28). It sits ABOVE the table it switches, so focus reaches it first, and it is drawn only where the payload names more than one emulator. The launching emulator's rows are the payload's own files less the library section's; another emulator's are its own requirements exactly as the resolver answers them, and they read and act like any other row (#2190 D30). The library files no emulator here claims (declared_by null, wanted not_needed or unknown — isUnclaimedLibraryRow in frontend/src/utils/biosTabs.ts) are no emulator's rows and stand in a section of their own, below. Choosing an emulator never changes what launches: the summary and its counts stay the launching emulator's, while the table, its per-row buttons and Download required follow the shown one. Then a table: File, On disk, Contents, and one button per row (#2191): Download where a RomM library file fills the row (library — by its content, or by name where the resolver's identity is an archive or names no checksum), RomM states its checksum, this platform's firmware store holds no copy yet and nothing that is not Tender's lies at the row's place (isFetchable) — the file lands in the store and is linked for every emulator that wants it and its source does not state is not installed, whichever the page shows; Replace… where the backend offers it (replace_offered: another file lies at the place, the resolver reads it as wrong, and the library copy is proven right by its checksum), behind a confirmation that names the exact path and the backup folder (backup_root); Delete where the row names a file of Tender's (delete_name — its store file, an older download of the library file that fills it, or an older download lying at the row's own place), behind "Delete <file name>?" / "Tender removes it from its firmware store and every link to it. Files you placed yourself stay." A folder the emulator lists is not one row but one per file in it (#2191 D26): each library file the resolver recognises for the folder, with its download, link and Delete, and each other file lying in it that the resolver makes a statement about, with its verdict and no button; a file it passes over because a valid image lies beside it gets no row. Where that leaves no row — nothing lies in the folder and no library file is recognised for it — one row stands for the folder, named by it, with the resolver's verdict over it and the code it words that by, no button, and the line "No BIOS image here yet" (D28); it goes as soon as a file of the folder has a row of its own. Under each row, beside the resolver's words, stand Tender's own (utils/biosPlaceWords.ts, the same on the game page's BIOS tab): a store copy not linked here yet, a link to another platform's copy ("Linked to Tender's copy for <platform>"), another file lying at the place, a file matched by name, RomM stating no checksum, a folder that does not exist yet, a place under a root the row's source's settings do not name or of a source whose roots could not be asked (D32), an emulator the source states is not installed ("<emulator> is not installed here, so Tender links nothing for it.", a place_refusal of reason not_installed, said in place of what lies at the place, D39), and what the latest attempt on this page did — a link that could not be placed, a damaged download, "Replaced. The old file is in <backup path>." — kept until the page is left or another Replace… on that row says otherwise, and never recorded (#2191 D22). What else a press did is said in the status line under the buttons: a replacement refused or failed ("Failed to replace the file: <reason>"), and a file a download moved out of the store ("A different <file name> was already in Tender's firmware store. It was moved to <backup path>."). The table, its buttons, their confirmations and lines, and where focus goes after a press are one module the game page's BIOS tab draws too (frontend/src/bigpicture/bios/, the presses in useBiosActions.ts; #2429 D3). Above the table, a sentence says where a download lands in the store only (store_only: an emulator the resolver does not cover, or no emulator source for the platform). The library's own section words each row by what the resolver's identification says its content is — "Checking…" until the answer arrives, then "Not recognised" or "Recognised as <names>" — asked one file at a time after the page is shown, because the resolver's identification takes about 0.25 s a file on the reference machine, kept by MD5 for the page and at most thirty minutes (utils/firmwareIdentifications.ts), and a store copy no emulator uses reads "In Tender's firmware store, used by no emulator" with its Delete. When a platform's pane is first shown with an answer, and after an emulator switch, the pane asks for the platform's links to be brought up to date (update_bios_links) and reads the platform again where that placed or removed one; a download's, a replacement's and that update's result say, in the status line, "<n> placed, <m> left in place: another file lies there" where something stayed in the way. Focus never falls back to the top of the page here (#2190 D32; bios/useBiosFocus.ts), which is where Steam puts focus that was lost, scrolling the pane there. A button that greys out keeps the focus: as Steam's shipped client code shows (steamui/sp.js), its DialogButton never disables its element — it adds the class Disabled, unwires its handlers and hides its A legend — so a press on it does nothing. That grey is the whole look of a greyed button: the red of a Delete label, and of "Failed", is drawn only while the button can be pressed. While a row's download or delete, or one of the bulk buttons below, runs, the pressed button stays enabled and every other one waits greyed; Steam's dialog hands no focus back, so a confirmed or cancelled delete puts it back on the pressed button; and where the work leaves the row nothing to press, focus goes to the row itself. Steam also scrolls the pane to every element focus lands on, so the position the pane stood at when the button was pressed — before its dialog, for a delete — is put back at once and again after Steam's own scroll has run, each time only while focus is still in the BIOS part. After a step of the selector, focus that was on a row of the table stays on the same file's row where the new emulator lists it, goes to the new table's top row where it does not, and to the carousel's shown entry where the new emulator lists no file; focus anywhere else stays where it is. Below the table one button, Download required for <emulator> (N), over the shown emulator's rows, sending that emulator's identity. N counts those rows that are fetchable and that the backend marks fetch_for_required — the rule the download applies too, which takes the options of a one-of group's uncovered regions as well as the files the emulator requires. Under a sub-heading of their own, For all of <platform> — SubHeading in layout/pane.tsx, smaller and quieter than a section title, so it reads as part of the BIOS section (#2190 D30) — the platform-wide pair, which no emulator choice touches: Download all, every file the library holds for the platform, fetched into Tender's firmware store and linked for every emulator that asks for it and is not stated not installed (#2191 D21, D36), and Delete downloads (N) behind a ConfirmModal, whose title and text say it takes only Tender's own downloads (#2190 D24). Each of the three is as wide as its label, never stretched across its row (#2429 D3). Then the library section, under the sub-heading "In your RomM library, asked for by no emulator here", with the same table and per-row buttons (a download from it lands in the store, linked once an emulator asks for it), the same whichever emulator is shown; one legend under both tables names the marks on either. The three bulk buttons 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. The one exception (#2190 D34) is Download required where no emulator has an entry: it is not rendered, because there is no emulator whose requirement it could fetch, which is a different state from nothing left to fetch; the backend fetches nothing for such a pick either (fetched_as_required answers False for no identity). A launching emulator nothing could identify has no entry, so where the platform lists others the pane opens on the first of them, and the button is that one's.

    Every sentence names the emulator, off the launching emulator's entry in emulator_tabs — its label, every label the pick's emulator goes by ("LRPS2 / PCSX2", as the selector names it), and the payload's own active_core_label only where no entry is the launching emulator's — the pick those very counts were filtered by, never the core read beside it on the page. That is the emulator's name; the platform header prints the launch entry's, and the two can differ (core-emulator-selection.md, "The BIOS part names the emulator; the launch places name the entry"). An empty required_count is worded "<emulator> marks none of its BIOS files as required" and a one-of group with no image in place "<emulator> cannot start this system without a BIOS image", because both are statements about one emulator's declaration. Whether the console itself starts without an image is the resolver's to weigh into the verdict, and it is said of the console in a sentence of its own (console_needs_image), so a subjectless sentence would state the count's conclusion as though the console had been asked. 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 is among its tabs for every game from the moment the page opens, and no answer takes it away. The cached detail carries no BIOS answer, so the tab's comes from the live get_bios_status read a moment later, and the tab tells three states apart off frontend/src/utils/biosReadState.ts, the derivation the platform page's BIOS block reads too: while the answer is still out, the BIOS heading over "Checking…" behind the throbber (WaitingLine); a read that failed with nothing shown, "Could not read the BIOS state. Open the game page again to retry."; a re-read that failed over an answer the tab holds, that answer under "Could not re-read the BIOS state, so what is below may be out of date. Open the game page again to retry." A read that comes back carrying no answer counts as failed — the flag with no level (a check that raised), or neither a status nor the flag (a ROM that is gone or has no platform). Which payloads are answers is the wire type's to say (BiosAnswer in frontend/src/api/backend.ts). A version switch starts the tab over at "Checking…", because the answer it holds is the previous version's. The answer that the launching emulator needs no BIOS is drawn like any other: its sentence with the green dot, the selector where the platform offers more than one emulator, and a list with no required row.

    The game page's BIOS tab reads the same sentence module (frontend/src/utils/biosSummary.ts) and shows the sentence alone, with the same ratio appended in the same words. Where the answering source gives no emulator list, a line between the BIOS heading and that sentence says why, in the platform page's words (emulatorDataReasonSentence, over the get_platform_core_info answer the tab already holds). There is no column naming the emulator (#2429 D2): the selector shows the launching emulator in its accent colour, and the sentence says whether it starts this game. Where the launching emulator states a one-of group, the tab lists it under that sentence — under a subheading "<emulator> · one image per disc region" in the section label's class (romm-panel-section-title, which upper-cases it), one indented line per option with its regions, file and state, always in the group's own order, the game's own region marked "← this game's region" where it stands (groupBlock in frontend/src/utils/biosGroup.ts).

    The game page's BIOS tab stands in two columns (#2429 D1). The left one is 760 CSS px wide and holds, top to bottom, the sentence and the group block, the emulator selector, the shown emulator's files, the buttons and the legend; the right one takes the rest of the width (at least 400 CSS px) and holds the library section. The right column is drawn only where the library section has a row; without one, the left column takes the full width instead of standing beside an empty column. Where the page is narrower than both need — the Deck's game page, where Big Picture's desktop one is not — the right column wraps below the left; nothing measures, the wrap is CSS's (BiosTab.tsx). Everything under the sentence and the group block is the platform page's part, drawn from the same module (frontend/src/bigpicture/bios/): the selector in the left column's gutter, aligned with the sentence and the table; the table with every row the shown emulator lists (#2429 D3), its marks, Contents and one button per row — Download, Replace… or Delete — under the same rules, confirmations and lines; Download required for <emulator> (N), which on the game page sends the shown entry's identity for the launching emulator too, since a game can launch with another emulator than its platform's pick; under For all of <platform>, Download all and Delete downloads (N); the status line under them; the library section; one legend for both columns — under the left column's buttons, dimmed, its entries side by side where the platform page's stand one per line. Every download and Replace… on the game page sends the game's ROM id, so the requirement it reads, the place it replaces at and the links it places follow the system the game's install record holds — the system the tab's rows were read for (platform_system(slug, rom_id) in backend/services/firmware/downloads.py); the platform page sends none and acts for the platform's main system. The game's BIOS answer carries what those buttons stand on, as the platform's does, the answer that nothing could be established included: the emulator entries, the delete counts (deletable_count, delete_name) and server_offline, which withdraws the downloads while RomM's listing cannot be read (check_platform_bios in backend/services/firmware/status.py); that answer still holds no row and no count. Every row is a focus stop (#2429 D6): a row with a button is reached as that button, and a row without one is a DialogButton around the row that does nothing when pressed — on the game page a Focusable around content that is not a control registers no gamepad stop (utils/scrollHelpers.ts), which is why that page's rows are not the table's own Focusable. Such a row keeps its own text colours while it holds focus, where Steam would paint them for a focused button, and shows focus with the fill and edge marker a pane's table row draws while its control holds focus (FOCUSED_ROW, layout/pane.tsx), through the .romm-bios-row-stop.gpfocus rule of the game page's injected stylesheet (utils/styleInjector.ts, which names the rule of Steam's it overrides). Each column is a Focusable with flow-children="column", so up and down move within it until its end, and the two stand in one with flow-children="grid": Steam steps left and right to the column beside, and down past the left column's end to the one that has wrapped below it, and nothing of ours measures which. Left and right enter a column on the stop it last had focus on, because grid flow enters a column sideways without a position to match, where geometric flow would hand it the position focus left from and it would take the nearest stop instead (FindFocusableDescendant in Steam's sp.js). Grid flow steps sideways only to a column overlapping the stop focus is on and down only to one clear of it, so both columns, and the focus group inside each, stretch to the row's height. Every stop in the tab is centred when focus lands on it, as a section is. A press that went through — a download or Replace… that succeeded, a delete that succeeded or stopped partway (delete_incomplete) — holds the tab's buttons until the tab's answer and the play row's are read again, whether or not it changed anything; a refused or failed press reads nothing again. A press on the platform page holds its pane the same way. The tab's answer is read through rereadBiosAnswer (frontend/src/bigpicture/panelEvents.ts), bound to the game shown and ordered like the event lane's own BIOS reads, the play row's through refreshBiosStatus. Where firmware changed, the press then tells every open game page, as the platform page does (romm_data_changed, bios); its own page skips that event, so a press costs one read of the tab's answer. What a press did stays until the game page is left, across a version switch too: the tab holds it for the platform the versions share. The play row's gear menu has no "Download BIOS" (#2429 D7); the red BIOS badge in the play row leads to this tab. The ratio is 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 … opens by name 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 Tender found at their destination (GLOSSARY.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. Nor does it count a row that reads "could not be established", which is no file missing: the ratio is the payload's own pair (ratio_server_count / ratio_local_count, over the rows whose verdict was established), and where no row was established neither surface prints one. The level, its label and the download buttons read server_count / local_count, which still count every row the library holds. 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 and biosGroup.test.ts over the region names and a file's place in a group. All three SWEEP the set they search rather than naming it (frontend/src/test-utils/componentSources.ts, which says which set and why). What the sweep cannot see is a NEW wording invented for one of these 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 two answers itself, and neither is a BIOS state: a row whose read has not arrived, and one whose read failed with nothing behind it. 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 both 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: a library file fills the row, RomM states its checksum, the platform's store holds no copy yet, nothing that is not Tender's lies at the place, and the resolver could look at the place (no firmware-path-inaccessible caveat, no unreadable refusal). A place the resolver could not look at gets no link, because nobody could see what it would stand beside (#2190; fetched_as_required leaves it out of Download required, and the store's link plan refuses it as unreadable) — and neither bios_level nor required_withheld is read anywhere among them. One table asks it for both pages (frontend/src/bigpicture/bios/BiosFiles.tsx), so a platform's pane and a game's BIOS tab list the same rows and offer the same buttons on them; neither page has a rule of its own for which rows it shows. Two further inputs sit beside that filter and are of the same two kinds rather than readiness gates: Download required counts the shown emulator's rows the backend marks fetch_for_required, that emulator's own demand, 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 of a download still gets words, in the same status line under the section, carrying the backend's own message, and the pressed button itself says Failed in red for two seconds before everything returns. For a DOWNLOAD that line is otherwise failure-only, except that the two batches (Download required, Download all) carry on past a file whose download is refused or meets a RomM error — any other fault ends the batch — and answer success, naming those files in a failed list beside the message; the line then carries the backend's sentence ("Downloaded 2 firmware files (1 failed: scph5501.bin)"). The button says Failed as well only when every file the batch tried failed. The platform-wide delete 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. Either Delete adds one line per folder where it left a link of Tender's because the folder lies under a root the source's settings did not name, or the source's roots could not be asked — "Left a link in <folder>: <source>'s settings could not be read. Tender removes it once they can be read." (#2191 D20, D27), the source named as Settings › Emulator sources names it — and one line per older download it left, saying why (D29): "Left <file name> in <folder>: <source>'s settings could not be read. Delete it again once they can be read.", "… RetroDECK is not installed. Delete it again once it is.", "… RetroDECK names no BIOS folder. Delete it again once it does." or "… it lies outside RetroDECK's BIOS folder, so Tender does not remove it. Delete it yourself if you no longer need it." — and a download adds one line per file it found in the store without a record and moved aside — "A different <file name> was already in Tender's firmware store. It was moved to <backup path>." (D25, D27). After a batch in which a file failed, the line names the failed files and still says what was left in place. These are lines of this status line alone, kept until the pane is left. A delete that could not remove every file answers delete_incomplete: its line says so, and the rows are re-read as after a success, because it may have removed files or dropped records before the file it stopped on. The platform-wide one names no file; the rows still offering Delete after the re-read are the files that stayed. An open game page is told of a BIOS change (romm_data_changed, bios) only when a run moved a file — a download that fetched one, a delete that removed one — because each page that matches pays a live BIOS read for it. The event carries the platform's slug and its fs_slug, and every open game page whose platform has that slug reads its answer again, because platforms that share a slug share their emulators' places (#2452 D16). The spinner is keyed on the run's platform 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 Tender's own records and nothing else — the row carries deletable_count and the delete_name the delete is asked by, which the backend derives from the same records the platform count comes from (_stamp_deletable): the firmware store's record of the file, or the downloaded_bios record of a download made before the store existed — of the library file that fills the row, or lying at the row's own place. The removal re-reads the record and takes the path it holds; a store file goes with every link of Tender's to it, and a link under a root the resolver only assumed, or of a source whose roots could not be asked, stays, named in the answer (#2191 D20), as does an older download Tender may not remove now, with why: while RetroDECK's settings cannot be read, RetroDECK is not installed, or it names no BIOS folder, it stays counted, so its Delete stays offered, and outside RetroDECK's BIOS folder its record goes (D29). downloaded is whether a file is at the row's place 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. Both buttons — the platform's and a row's, a file of a folder's included, since each file there is a row of its own (#2191 D26) — run one removal loop (PlatformBiosDeleter._delete_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, the resolver's per-file verdict, and never off presence: a wrong image is there and ✗, and for a folder declaration the two come apart entirely. The colour is the need: strong where the row's emulator requires the file — the launching one, or the shown one on another emulator's rows (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". Every "not established" is a grey ? (#2190 D7): a verdict nothing could establish, and a wanted: "unknown" row — no placement in the platform's catalogue, and the launching emulator's reading incomplete (reading_complete_for) — whose need nothing could establish, which must not wear the grey ✗ that means "not required". Whether that row's file is there stays in its title and the legend (two lines: "here; nothing could say whether this is wanted", "missing; …"), because a platform nothing could be asked about is made entirely of such rows. optional and not_needed 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 an option of the launching emulator's one-of group (one_of). An option alone never makes its row required_by_active — the group is the requirement, and no one option is — so the four-way scheme drew grey "missing, not required" marks under a headline saying the console needs one of them. An option is drawn strong like a required row instead: green ✓ where it is there ("one of these, here"), red ✗ where it is not ("one of these, missing"), because the regions it serves have nothing else to boot. Which region that is is the row's need line — one of these · for Japan discs, or one of these · for every region for an image its core starts every disc from — and whether the image is there stays the mark's alone. Regions are worded USA, Japan and Europe, as RomM and the BIOS descriptions name them. The two grey ? states above are tested FIRST and are not displaced.

    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's entry on it (firmware) 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: glyph plus colour names several sentences at once (red ✗ and green ✓ each mean two things, and the grey ? three), 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 need-unknown 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" line below them.

    On a platform nothing could answer for, the rows nothing could be asked about stand in the library's own section, each saying "need not established" under its name, and no line counts them; the summary above states the condition without a number. 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 the table can drop it without re-deriving the helper's precedence. 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 ("Tender could not look into bios/kronos/, so whether saturn_bios.bin is there is not known", "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 but for rejected, which is Tender's (a file in a declared folder the emulator's own test turned down), carried from the same entry declaration is. Three of its nine 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, the description of a declaration atlas packages (hidden by the rule below), 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 the table therefore applies on both pages. 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 table's clipped line. 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. A row is headed by the file the emulator declared rather than by the packager's prose, which is not the file's identity, so without the rule the line under the row would print the name a second time on every shape that opens with it.

    What is left goes on a muted line under the row, clipped to one line (nowrap + ellipsis). That is a column width: the name sits in a table cell that clips — about 202px on the platform pane — and it already gives 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 the call site (BiosFiles.tsx), because putting the label back on the name line is the obvious-looking change that breaks it. The declared folder goes the other way, 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. The need is therefore the name cell's second line, and a row can carry two kinds of line under it — the description, then biosFileNote's note — neither of them in a cell.

    Every row says what the shown emulator needs of its file, on both pages and whichever emulator is shown — as a second line under the name inside the name's own cell, the two lines the row's button is centred against — worded by needWords in frontend/src/utils/biosGroup.ts: "required", "optional", "one of these · for USA discs" for an option of the shown emulator's one-of group, and in the library's own section "not required" where the launching emulator was read and declares nothing for the file, and "need not established" where nothing could answer for it (wanted: unknown). A row's mark carries the need as a shade at most — a met required file and a met optional one are both green — so the words are what tell them apart. The marks' tooltips keep their own wording. Contents words what the resolver's check found at the row's place (contentsWords in frontend/src/utils/biosFileNote.ts, #1803): for a folder declaration 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), that it holds none, or that nothing established its contents; for a file the resolver's checked in words — right file, wrong file (not the image expected, or not an image accepted), refused, not comparable, not recognised, could not be read, not checked, not established — the longer ones wrapping inside the 84px column rather than cut. A file of a folder declaration that has a row of its own carries its own checked, never the folder's: the resolver states a verdict per file and no check state, so services/firmware/rows.py words an identified image verified, a file the emulator's test read and turned down rejected — Tender's own word, "wrong file — not an image <emulator> accepts" (#2191 D41) — and anything else unknown. They explain the mark beside them and never contradict it, and the word "unknown" is not one of them. A file nothing was asked about — one that is not there — reads an em dash, and the dash must never come to mean "asked, and nothing found". The section appears whenever the firmware read speaks for the platform, synced or not.

  • Remove — Remove N shortcuts and Delete N save files on one row, the actions the Data Management platform modal used to offer, without the BIOS delete (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 run once per page mount: get_platforms (RomM's platforms with ROMs, the list itself), get_firmware_status (whether RomM's firmware listing could be read, which the BIOS downloads stand on, and nothing about any platform) and get_registry_platforms (ROMs bound to a Steam shortcut per platform — the shortcut counts, and what "has synced games" means here; one entry per platform, by its fs_slug, and a bound game with no fs_slug recorded counts on no row, #2452 D17). Only the first gates the list; the other two fill in beside it. A failed get_firmware_status leaves the downloads offered, as they are while it is in flight, and a download the server cannot serve says so itself. A failed get_registry_platforms says so on the pane, because a failure and an answer arrive the same way — as an absence: read as zero shortcuts would print 0 versions 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 platform's fs_slug).

The fourth is get_platform_firmware_status, and it is asked once per platform rather than once per page — for every platform the list shows, whether or not the RomM library holds firmware for it or any of its games is synced (#2456 D1) — because what a platform's BIOS state IS costs a live per-system reading of the machine. Measured on the reference device at emu-atlas 0.22, the median of five runs each over the 25 platforms its library names, with the platform listing read from the firmware cache rather than a live server: the reading itself is 59-175 ms per system and a platform's whole answer 114-246 ms (one platform with no system reading answers in under 50 ms), so one call answering for all 25 would put at least the 1.8 s their readings add up to in front of the first row — where a call that merely named them took 171 ms. The page therefore renders from the cheap reads and the answers arrive underneath it, and five rules hold:

  • The frontend owns the order. It walks its own list top-down — Synced above Available, each by name, the order the list is drawn in — 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.
  • Every platform it is asked about answers with its entry. A launching emulator that was read and needs nothing answers met, and the pane says the emulator marks none of its BIOS files as required; a platform whose reading did not happen — no system, no emulator source, a refused or sealed catalogue — answers that nothing could be established, never that nothing is needed (#2456 D2).
  • A row waiting for its answer is drawn and worded apart from one nothing could be established for. The waiting row holds no entry yet and the other holds one whose level is unknown, so the level cannot tell them apart — the row's read state (pending against answered) does. Drawn off the level, both would 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. Its pane says "Checking what this platform needs…" behind the throbber (WaitingLine), as a game's BIOS tab waits for its own answer. The read state is derived in frontend/src/utils/biosReadState.ts, which the game page's BIOS tab reads too.
  • 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 nothing could be established for, 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 reads issued once per selection and cached for the life of the page: the core read asked by the platform's slug and its fs_slug, the save count by its fs_slug (#2452 D11). 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. 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, in which the resolver looks up each entry's emulator on the machine) 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 platform, 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 (GLOSSARY.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 (GLOSSARY.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 Tender 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 (GLOSSARY.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. Nine 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 Tender touches its saves — and Emulator sources, Tender folder, Updates and Interface are new.

Section Holds
Connections the services Tender 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.
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)
Emulator sources every emulator source the resolver detects, in the user's order, one card each: a round badge with its place in the order (filled with the selection accent while the source is switched on, a grey ring with a grey name while it is off), its name with two icon buttons at the end of the same line to move it, since a controller cannot drag — chevron up and down, showing no text and carrying Move source up / Move source down as their accessible names; dead at either end and while a change is in flight, in the handler too — the folder it lives in (left out while its settings file is missing or broken, or while the source has not been set up yet, since that folder is then a default), a line per health finding — or, with none, No problems found. where its emulator list was read — and Source's emulator list is not established. where it was not and no line says why (a finding about ES-DE's systems file, or that the source has not been set up yet, does; any other finding leaves the line standing beside it) — and, where they hold, Source's emulator list cannot be read yet. and Tender cannot start games through source yet. — each line led by an icon of its tone, which the wording module returns with it (sourceRowLines): a green check for No problems found., an information sign for the cannot-start line, a warning sign for every other; then, under a hairline, Use this source. The two arrows are one focus group laid out as a row; the folder-and-health lines are no stop of their own, since the arrows above them and the switch below bring them into view (§ Two widths states the rule). A switch or a move answers the listing as it now stands, and one that is accepted has every open game page read its emulators, its active emulator, its BIOS state and its save details again (romm_data_changed emulator_sources), since which source answers for the game may have changed; a refused one reads the listing again and tells no game page. On a move, every card whose place changed slides from its old place to its new one, about 200 ms eased out — nothing slides on the first render or when the list comes back after a notice — and once the new order has arrived focus goes back to the arrow that was pressed, or to the card's other arrow where the source reached the end its own arrow is dead at, so a source moves several places by pressing again; a reader who went outside the cards in the meantime is left where they are. While the listing loads the section says Reading the emulator sources…, where it cannot be read Could not read the emulator sources. Reopen the page to try again., and with no source No emulator source was found.
Tender folder where Tender keeps its own files (useTenderFolder, settings/TenderFolderSection.tsx, settings/AnotherFolderModal.tsx): a status line naming the place in use (In use: Internal storage, In use: kind · name, In use: Another folder), a small pill with its state (Reachable, Not connected, Missing, Not writable) and the folder under it, set off by a divider. It is no stop of its own: it sits above the first tile, and the detail column's ScrollRegion brings it into view when focus reaches that tile. A drive's name comes from what was stored with the choice (drive in the state), and its kind from the drive itself while it is connected, as its tile reads it, or from what was stored once it is gone, so a drive that is gone is still named. Then a warning card with the sentence of a refused tile; then one tile per place, two to a row, each row one focus group laid out as a row — the internal storage, then each writable drive — each headed by its kind (Internal storage, or SD card, USB drive or Drive, read off the block device behind the mount by the folder adapter), clear of the In use badge's corner; under it one pill tag, green Default on the internal tile and violet with the drive's name on a drive's (neither blue, which the badge and outline are); then the folder it would use, and at the tile's foot, on its right, size free, so the tiles of a row line up. The tiles and the row after them are Focusable stops that draw their own look rather than DialogButtons, whose focused state Steam paints white: a dark fill, and under focus a lighter fill with the selection accent as an outline. The tile of the place in use keeps an accent border in both states, carries In use in its top-right corner, and does nothing when pressed; a drive in use that is not connected keeps its tile, greyed, with Not connected in place of the free space, and one that is connected but not among the writable drives (mounted read-only, say) keeps its tile outlined and not greyed, without a free-space line, its state on the pill. A path breaks only after a slash. With no drive, a line says to insert an SD card or connect a drive. Last one row, Another folder… with the line under its label and a chevron. It opens one window built on ValidatingModalShell that stays open until a place is accepted: an empty text field labelled that Tender puts its folder named Tender inside the place entered, the example under it, then under the field either a refusal in amber with the text kept, or one of the two questions with its buttons — Use parent / Keep as typed / Cancel for a place whose last folder is named Tender, Create / Cancel for folders that do not exist yet — and the footer's OK and Cancel. Enter in the field and R2 — Steam's own OK for the window — do what OK does, held back like the button (the shell's dialogOKSubmits, whose docstring says what Steam does without it). Editing the text drops a shown refusal or question, and an answer that arrives after an edit is dropped too, so a question's buttons act on the text that was checked — all but a choice that went through, which has switched the folder and still closes the window; a question's Cancel drops the question; an accepted place closes the window, and the footer's Cancel closes it with nothing chosen. Nothing can be pressed while a change is in flight, and a switch raises no toast: the status line and the outlined tile say where the folder now is. The sentences are utils/tenderFolderWording.ts's; a refusal naming an emulator source is worded there with the source's name, every other one as the backend worded it
Updates installed and available version, the install (below), "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 where the rows above do not say it. Home of the update notice.
Interface where Tender's Quick Access entry sits beside Decky Loader's: above it (the default) or below it — § The entry
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. Tender 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. Three kinds of line are not, each for a reason of its own. 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. 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. And an emulator source card's folder and health lines sit between two stops of that same card, which § Two widths states is enough.

The install under Updates is the one part of the page that reads on a clock. While the section is on screen it reads get_update_install_state every three seconds, one read at a time — a call made while the connection is down waits for it rather than failing, so an interval that kept issuing would queue one read per tick behind it — and stops when the section unmounts (bigpicture/settings/useUpdateInstall.ts). The button is there while the backend offers the stored release (offered), and stays through an attempt — and, until the section's first read answers, beside any attempt this panel already knows of, disabled since no version is known yet, so an attempt that ends before that read does not take it away under focus: it reads Install update — the version is the Available row's to name — Try again where that version already failed here or was rolled back or refused by the pre-install check, or before that first read where the attempt beside it failed, and Installing…, disabled, while an attempt is under way — while an attempt is under way, nothing that can hold focus unmounts under it. It is disabled while any wait reason holds, and a press on it then is refused in the handler too, since a disabled control still reports a press on the device. What it waits for — Waiting for: with the reason on the same line, or a line per reason where there are several — rides the button's own description, with the paused-download hint, what refused the last press and Could not read the update state. after a read that failed or did not answer within five seconds (UPDATE_INSTALL_READ_DEADLINE_MS; the late read stays the one in flight), so none of them is a row that can leave while it holds focus; the reasons go by themselves once a read no longer names any. Where there is no button, that line rides the Available row's description instead, which is always there. The backend answers in discriminants; the sentences for them are in utils/updateInstallView.ts, and the button's labels, the block's captions and its failure titles in bigpicture/settings/UpdateInstallRows.tsx; the sentence an update that did not go through is stated in is utils/updateOutcomeStore.ts's. A refusal is worded by the panel, not by the backend's message, and goes once a read says it no longer holds (refusalStands). The fallback page reads the install through the same hook and words it with the same sentences, where the panel does not mount (One home per action). After a press the attempt comes from two sources, the update_install_progress frames (utils/updateInstallStore.ts) and the reads — on the fallback page, where no frame is listened for, from the reads alone; whichever got further wins, and a press fences off the reads that could carry the attempt it replaces (furtherAttempt, useUpdateInstall.ts).

What is happening is one block, between the button and the daily-check switch: one focusable field whose content changes, so the focus stop stays put from the download through the restart to a failure. While an attempt is under way it holds a caption — what is happening on the left; NN% · m:ss, or m:ss, on the right in tabular figures — then Steam's own ProgressBar, determinate while a download with an announced size runs and indeterminate otherwise, as Main's sync slot uses it (the panel draws no animation of its own), then the step line — Download, Verify, Check the new version, Install, wrapping where the pane is narrow — each step's mark coloured and its label in the text colour: done ✓ green, current ● in the selection accent, still to do ○ muted, failed ✕ amber; then a line under it: Starting a game now cancels the update. while it downloads and verifies. The clock is the panel's own — the attempt on the wire carries no timestamp — and counts from when this panel first saw the attempt under way, in the press's answer, a frame or a read (attemptSeenAt, utils/updateInstallStore.ts); like the moment the installer was first seen, it survives the section leaving the screen but not a reload of Steam's JavaScript context.

From installer_started on, the phase is the panel's inference, because the backend reports nothing more. While the reads still answer, the installer is running the new version's pre-install check, which it does before it stops anything: the caption says Checking the new version, with that step current, and nothing stands under the steps. Once a read fails or passes its deadline, the installer has stopped this backend: the caption says Tender is restarting, with Install current, and the line under the steps says Steam's interface reloads when it is done — usually within a minute, and up to about 5 minutes if Tender has to go back to Y., Y being the installed version. Once a backend answers again, it closes this panel's upgrade as stranded (loading-the-panel.md), and the stranded card takes the place of the whole panel, this section included; while an attempt has started the installer, the card adds The update's result shows after that. (installerStarted, utils/updateInstallStore.ts). It claims no outcome, because the backend answering may be the earlier version after a rollback and the refusal does not say which; the reload shows the outcome on Main. The phase follows the last read, so a read that fails once during the check shows the restart until the next one answers. The seven minutes of the mark below count from the installer's start, so they cover the pre-install check (up to two minutes, CHECK_SECONDS in install.sh, and ten seconds' grace before it is killed), the wait for the new version to answer (up to a minute, UPDATE_WAIT) and, on a rollback, up to a minute more for the previous one; stopping the service, backing up the data and the reload that ends this screen are not bounded, so the mark is where the panel stops waiting quietly rather than a sum. The restart line's "about 5 minutes" leaves the check out, since it stands only once reads stop answering, after the check. A version the check refuses ends in a failure without a restart at all. From installer_started on, a lost connection is the expected outcome — the installer stops this backend — so a read it takes down is not logged, Main's connection row stays at Checking... rather than calling the backend failed, and Check now is disabled while an attempt is under way — in its handler too — since a check in flight when the connection goes would fail on it and say so. Seven minutes after the panel first saw the installer started (INSTALLER_OVERDUE_MS, counted from that moment across the section leaving the screen), the line under the steps gives way, in amber: Tender has not come back. with the journal and the command to start it where reads no longer answer — a call to a backend that is gone waits rather than fails, so it is the read deadline that says so — and The installer is taking unusually long. with the journal where they still do. Neither is seen once a backend answers again: the stranded card stands in place of the panel then. From the seven minutes on, the connection row is left to its own verdict too: a probe that held it at Checking... asks again when they are up.

A failure is the same block in amber, drawn in the frame Main's update cards use (cardFrame, bigpicture/UpdateCard.tsx): a title, the step line with the step it stopped at marked failed — where the failure has one, as the table below says — and the reason, with where to read more for the installer's own failures; Try again is the button above it. A failed attempt of this backend's is titled Update to X failed — nothing was changed., and one cancelled for a game — game_started, or running_apps_unknown where Tender could not check whether one runs — Update to X was cancelled — nothing was changed.; an installer that stopped where this panel did not see it start says — you are still on Y. as its card on Main does, since what it left behind is not known here. Its reason is its sentence in INSTALL_FAILURE_SENTENCES, which leaves out what the title says and, for the installer's own failures, names its journal; Main's card for a stopped installer takes the same sentence, and the toast the reason without the journal (INSTALL_FAILURE_REASONS). The step it is marked at (failedStep, utils/updateInstallView.ts):

Failure Marked at
download_failed Download
checksum_mismatch Verify
new_version_does_not_start Check the new version — the installer's own failures are marked at the check, so no step the attempt did not reach is marked done
installer_stopped, where this panel saw the installer start Check the new version — it ended before it stopped this backend
installer_stopped, where it did not Install — the installer had stopped the backend before the one that found it
installer_not_started, game_started, running_apps_unknown no step line — the attempt ended at the installer's start, through no fault of the new version

Whether this panel saw the installer start is installerSeenAt (utils/updateInstallStore.ts), a proxy for whether this backend's watch saw it end: the two agree while the panel that saw the start is the one still loaded. A reload of Steam's JavaScript context forgets the sighting, so a watch-reported installer_stopped read after one is marked at Install. The other mismatch does not arise: a panel an earlier backend loaded cannot read the backend that found the record at its own start, which refuses its socket (a panel an earlier backend left behind).

Where no failed attempt of this backend's is shown, the installer's record takes the block: Update to X failed — Tender went back to Y. at Install for a rollback, Update to X failed — nothing was changed. at Check the new version for a refusal by the check, and the card's sentence with no step line for a kind this version does not know; the reason is the record's own line (updateFailureReason), except a refusal's, which leaves out what its title says (UPDATE_CHECK_FAILURE_NOTE). An attempt takes the block over a record, so a refusal by the check this backend saw while it ran — a failed attempt and a record at once — is stated once. A failed attempt shows only while its version is the one offered, or until the first read answers, so the block does not leave under focus, and leaves once a read offers another version, or none; a record standing then takes the block. Check now reads whatever the switch says, and its own line under its button says only what the rows above do not: GitHub gave no usable answer. Try again later. and The check failed. — a release found is the Available row's to name, and so is none newer.

Show what the installer said is a row of its own directly under a failed block, a ButtonItem — the block's line still says where to read the reason by hand — where the installer ran: an attempt that failed as installer_stopped or new_version_does_not_start (INSTALLER_RAN, utils/updateInstallView.ts) — a stopped attempt an earlier start found among them — and a record of every kind. It is not a control inside the block's field, which would change that stop; it comes and goes with the block, so it leaves when a read offers another version or none, and when Try again starts a new attempt, whose block has no button while it runs. A press opens a modal at once (bigpicture/settings/UpdateOutputModal.tsx), through the showModal and ModalRoot the panel already imports, so the start-up check has no new name to classify; it says Reading what the installer said… until the backend answers for that failure (get_update_output, with the record's rolled_back_at, or null for this backend's attempt), and a press while that answer is on its way opens nothing. The modal renders in Big Picture's document rather than the QAM view's; it reads no DOM global, so the two realms do not meet in it. Where it shows output it is titled What the installer said — HH:MM, the local time the installer's run began, and holds The installer and, after a rollback, X, when it tried to start — which run each is, and what the backend cuts, hides and folds, is UpdateOutputService notes. Its lines are monospace and wrap, each in the colour its start gives it (outputLineColour) — a step that failed ([!!]) and the installer's own failure lines (install.sh:) amber, a step done ([ok]) green, a step under way ([..]) muted, anything else the text colour — and the dialog scrolls as one, as the cleanup modal does; since a region scrolls only by moving focus, the lines are cut into stops of at most twelve lines and 1200 characters (outputStops) — a line of 500 characters wraps to several rows, and a stop taller than the dialog would hide its own end — each a Focusable declaring focusableIfEmpty, so a controller walks several hundred lines one stop at a time and every line is reachable. A part that leaves lines out says how many above them. Everywhere else the title carries no time, and one stop says why there is nothing instead of an empty box: This output is no longer in the system journal — it keeps only the last hours of logs. (missing rotated), This update was run in a terminal, so its output is there, not in the journal. (terminal), The installer left nothing in the journal for this update. (empty), This failed update is no longer on record. (not_found), and Tender could not read what the installer said. where the journal or the call failed, and for a missing value this panel has no sentence for. Close ends it. Main's cards keep their journal sentence and Open Updates; the button is Settings' alone.

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 RomM URL's modal saves before it closes and only a URL the backend stored closes it, so the row and the next open of the page show 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.

The RomM URL's modal checks the URL the way Another folder… checks a place: OK, Enter in the field and R2 save it through the backend, a refusal — or Failed to save settings where the backend could not be reached — is shown under the field in amber with the text kept, editing the text drops it, and only a stored URL closes it on OK, Enter or R2. One refusal changes the text instead of keeping it: a URL that names no scheme at all is refused scheme_missing with completed_url, the URL with https:// in front, checked by the backend as any URL is, and the field takes that URL with the refusal's line under it, so the next OK saves it. Nothing is saved on the first OK, and Tender never tries http by itself. What counts as naming no scheme is lib/url_host.py::with_https_added's; a URL whose completion is no valid URL either (a blank one) is refused as before, and one whose completion carries a user name or password gets that refusal instead. Like any refusal, a completion that arrives after the text was edited is dropped. Cancel and B close it at once; a save OK already sent still lands, and the page takes up the URL it stored. The Allow Insecure SSL switch saves the URL again with its flag; a refusal of that save puts its message on the section's status line, and the switch goes back to the stored flag, or, where none is known, to where it stood before the press. An accepted save of either puts the switch at the flag it sent, and clears the line of a message the switch's save put there; what loading the settings or signing in said stays. The status line shows a refusal or a failure — of a save, of loading the settings, of signing out — in amber behind a warning sign, and any other message plain.

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 Tender's entries the whitelist, the removal, the RetroDECK guard
Gone from RomM scan until asked — a local scan and one request to RomM, for the ROMs the user hid 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: the throbber 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… behind the throbber (a scan's own button says it is scanning, behind the throbber too, 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 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 scan of the grid directory for one row, and for the other a scan of the library's records, each row against its platform's completion stamp and its gone-from-RomM mark, and of the downloaded files' sizes, with one request to RomM for the ROMs the user hid (Removed-game cleanup). 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 shortcut Tender created but 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 Tender'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 Tender 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. Each install records what RomM states for the files a download of the game fetches (RomInstall.fetched_size_bytes; an adoption records the same figure, #2732 D1) — a download leaves a game's manuals, soundtracks and other extras on the server, so this is not RomM's size of the whole game — and the page sums it over the installs, so it opens with a number instead of measuring for one. 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 an install that recorded no size adds nothing, 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 summary, the options, the run's bar, then the list. The summary says what a cleanup would do — RomM no longer has N versions this device still holds. Each one is checked against RomM again before anything is removed. If you clean up now: and the counts of versions removed, games removed with their shortcut, downloaded files deleted with no copy, and versions kept because a shortcut is on them, each left out where it is zero; with no version removed it reads If you clean up now: nothing is removed. beside the kept count, and Confirm is disabled. N is the preview's candidate_total, the versions it offers for removal, in the trash icon's dialog too. The four options stand in one column, each a short label with its meaning in the line under it: Remove gone versions, Remove games that are gone entirely, Back up before removing with the acknowledgement a run without a recovery bundle asks for under it, and Back up downloaded game files too, which switches every row's backup at once and carries the space line — Selected: X of Y · Z free, X the files whose backup is on, Y every file the run would delete — with the lower-bound note, any refusal for space and Refresh free space under it. Where the run would delete no downloaded file, and every page has arrived, that option is disabled and its line says there is nothing to back up. The bar holds Retry loading once a page has failed to load, Cancel and Confirm, which names its count (Remove 3 versions), red and last; the reason that page failed, 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 list, 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. Retry loading is in the bar for that reason too: every row is a stop, and Confirm refuses until every page is loaded, so after the list it would be a list's length from the press it unblocks; the rows it adds land below it.

The list is grouped by game and says what a run would do, not only where a version is. It is a table (Game and versions, What happens, Downloaded, Back up file) with a line under it saying that Downloaded is the game files of that version on this device and a file is deleted only where the row says so. Each game is a row of its own, a focus stop, with its name, its platform, how many of its versions are gone and its outcome. The name is that of the version a Steam shortcut is on, the name the game has in Steam; with no shortcut, that of the group's default version in RomM as the last library sync stored it (is_main_sibling); otherwise the first version's. The outcome is Game stays, Nothing removed, Game removed, with its shortcut, or Game removed for a game without a shortcut. Its versions follow — the gone ones, then the one a shortcut is on, then the others on RomM — each with its file name, where it is (Gone from RomM, On RomM, your shortcut on the version a shortcut is on) and its outcome: Removed, Removed, file deleted, Kept with its reason, or Stays for a version on RomM. An outcome is red where something is lost or leaves Steam, light where only Tender's own record of a version goes, blue where it is kept, and green where it stays. The outcomes follow the options. They are predicted in the panel (bigpicture/cleanupPlan.ts) from the preview's per-row fields — gone_from_romm, bound, candidate and fs_name_no_ext — and the run's own check still decides; the run never removes more than the prediction (Removed-game cleanup). Where two reasons keep a version, the shortcut's wins, also over the trash icon's Only the version you chose is removed here., since nothing else in the dialog would remove it. A gone version beside a version on RomM with the same file name less its extension is an old entry RomM replaced in a rescan, and says so where the others say where they are. More than two other versions on RomM fold into one row, N more versions, all on RomM · Show, a button that opens them in place; the version a shortcut is on is never folded. Every row stays listed with either option off. Nothing in the list is cut short: a game's name and outcome and a version's name and where it is wrap onto a further line where they do not fit. Game and versions takes three fifths of the width the two text columns share, and What happens sets its outcomes and reasons a size smaller, so the names have the room. A game's name is bold and one step above its versions' names, which take the version rows' smaller size. The Back up file switch appears only on a row whose downloaded file the run would delete, under the column's header Back up file and with no label of its own, and is that row's focus stop, since a second stop on the row would be a dead step in front of it. It is Steam's bare switch (Toggle), right-aligned in its column, and the row shows that the switch holds focus, as Library › Collections' rows do; every other version row is a stop itself, so the list can be walked. With Back up before removing off the switches are off and disabled. A switch the reader turned on stays on while an option takes its row out of the run, but only a file the run deletes is counted or staged. A row's warning — the preview's own — is a line under it, inside the row. The dialog is revealed at both edges: focus reaching the first option scrolls it to its top, so the summary comes back, and focus reaching the last row scrolls it to its end, so that row's own lines and the note after the list 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
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; fallback page Settings › Updates; fallback page; Main shows notice
Install a Tender update Settings › Updates; fallback page Settings › Updates; the fallback page too

The install's second home is a deliberate exception. Where a start-up search the panel cannot render without came back empty, the panel does not mount and the fallback page stands in its place — and the release that repairs that is often a Tender update, which Settings › Updates cannot install from a panel that is not there. So the fallback page offers the install and the check for a newer release as well (its Check for updates is Settings' Check now), worded with Settings' sentences, wherever its own sentence names updating Tender (frontend-bundles.md).

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's class maps and DOM 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 (both since removed). 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, the input_driver fix and the Settings homes it draws for both no longer exist, and the device-list study above draws the save-sort notice too. 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.