QAM panel¶
The Quick Access Menu panel is the plugin's own surface inside Steam's QAM, behind Tender's own entry in the tab strip — beside Decky Loader's where Decky is running, and on its own where it is not. It opens on Main and reaches every other page from there. Steam renders the QAM 348 px wide; a page of this plugin can widen it to 854 px — the width Steam's own Friends tab uses — for as long as that page is mounted. This page owns the panel's structure: which pages exist, which are wide, how a page is navigated and laid out, and where each action has its home. The game detail page is a Steam route, not part of the panel, and is out of scope here; the state it shares across its surfaces is the Game-detail store (CONTEXT.md).
The structure below is the target decided in #1809 and rebuilt one page at a time under #1808. Where today's panel differs, the difference is stated; the PR that lands a page updates its row in the page table. The vocabulary — QAM page, Main, wide page, list and detail, notice, home — is defined in CONTEXT.md and used here without restating it. The width mechanism's decision record is ADR-0029.
Where the code lives¶
| Module | Responsibility |
|---|---|
frontend/src/qam/ |
The entry itself: the patch that puts it in the strip, the tab glyph, and the boundary the panel renders inside |
frontend/src/index.tsx (QAMPanel) |
The router: one Page value, one mounted page, a module-level currentPage that survives a QAM remount |
frontend/src/types/navigation.ts |
The Page union — every page the router can land on |
frontend/src/bigpicture/MainPage.tsx |
Main |
frontend/src/bigpicture/SyncPage.tsx, frontend/src/bigpicture/sync/ |
Sync — the frame and its three left-column bodies, plus useSyncPage (its reads and actions) and the numeric-column split both its tables take |
frontend/src/bigpicture/LibraryPage.tsx, frontend/src/bigpicture/library/ |
Library — the frame, and in library/ both tabs and their state (its own row, further down) |
frontend/src/bigpicture/SettingsPage.tsx, frontend/src/bigpicture/settings/ |
Settings and its sections |
frontend/src/bigpicture/DataManagementPage.tsx, data/, RemovedGamesCleanup.tsx |
Data Management — the inventory rows, their panes and the page's state |
frontend/src/bigpicture/DownloadQueue.tsx |
Downloads |
frontend/src/bigpicture/library/ |
The Library tabs: usePlatformsPage, PlatformsTab, PlatformDetail; useCollectionsPage, collectionKinds, CollectionsTab, CollectionsDetail |
frontend/src/utils/deckyUiInternals.ts |
Honest typing for @decky/ui values that come from a webpack probe: the frame's class names, Tabs, ScrollPanel, the controller glyph |
frontend/src/utils/qamExpansion.ts |
The panel's width: the expand and hide messages, the injected max-width rule, and the four paths that clear both |
frontend/src/bigpicture/layout/ |
The wide-page frame: WidePage (the Back/title line, tabs, measured height, entry focus), ScrollRegion, Columns, ListDetail, pane |
frontend/src/utils/entryFocus.ts |
Which stop a body opens on, a page's declaration when that stop is not it, the rule for a body that swaps under the reader, and the .focus() + gpfocus pair |
frontend/src/utils/syncRunView.ts |
useSyncRunView — the run in flight as a page renders it: stage label, coarse bar, position within the running unit, fine-detail line, estimate, and the run's end |
frontend/src/utils/runUnitsStore.ts |
The run's work queue, one row per unit: the plan's riders, how far the run has got, and what each unit's apply produced |
frontend/src/utils/previewState.ts |
What a page asks of a pending preview: has it anything to apply, and how long is it still accepted (the half Main reads) |
frontend/src/utils/syncResume.ts |
Whether the next sync continues a run or starts one over, and what that puts on the Sync page's start button — the name the session-budget card quotes |
frontend/src/utils/syncProgress.ts |
The frame every page reads a run from, and the one rule it enforces on its writers: a run that has ended stays ended |
frontend/src/utils/ module stores |
State that must outlive a page: sync progress, pending preview, downloads, prune, the game-detail caches |
The entry¶
Steam ships no API for adding a Quick Access tab, so frontend/src/qam/quickAccessEntry.tsx patches the two renderers
that draw the menu — the browser view Gaming Mode uses and the embedded one — and pushes an entry into the tab array
they hand back. Decky Loader patches the same two renderers, and the two compose: afterPatch chains handlers rather
than replacing them.
The entry's heading is an element carrying Steam's own heading class (quickAccessMenuClasses.Title), with the
plugin's name inside it, rather than the name as a string. That is a measurement and not a preference: in the running
Quick Access document, Steam's own tabs head their panels with an element carrying that class, drawn at 22 px / weight
700, while a bare string lands as a plain text node in the panel container at body size, 16 px / weight 400 — which is
what the entry shipped with. Without the class map the start-up check has already refused the panel, so the heading
stands over the start-up failure page, and it is drawn unstyled rather than not at all. The panel's content carries no
heading of its own: Steam draws the tab's, and a second would be two.
Nothing in this path touches Decky. Not window.__TABS_HOOK_INSTANCE, whose deinit() Decky's own constructor
calls on whatever it finds there; and not its add() either — Decky's render counts its decky-marked entries against
its own list length, and a foreign entry desynchronises that guard into re-pushing every tab with no convergence. The
entry carries Tender's own marker and Tender's own key (tender), so it is invisible to that count. None of this is a
presence check: the choice of bundle already asked whether Decky Loader is serving, from the machine rather than from
the window (loading-the-panel.md), and the frontend does not re-ask.
Where the entry sits is not ours to set once. afterPatch runs the previous handler first, so whoever patches last
pushes last and lands lowest; measured both ways in the spike behind
#1897 — patched before Decky booted, Tender came out above
it, and re-patched beside a running Decky, below. Install order is a property of which program starts first, so the
placement is re-asserted on every pass instead: the handler moves the entry to the end of the array each time it runs.
Moving an entry is invisible to Decky's guard, which counts marked entries and never asks where they sit.
Every Quick Access remount builds a new tab array — Gaming Mode to Desktop and back, or Big Picture opening in a window, replaces the menu's browser view and with it the React tree, the array and the document. Three consequences, and each is a way to get this wrong:
- No array is held. The spike kept every array it had pushed into so that it could take its entries back out; that set only grows, one dead array per remount for the life of the process. There is nothing to take back out here — the injector refuses to load the panel into a context that already carries its marker, and what clears that marker is a JS context rebuild, which takes this module and its patches with it — so the module holds no array and every dead one is collectable with its view.
- The entry is added again to whatever array the pass is handed, and the entry's own marker is what keeps a second pass over an array it is already in from adding a second one.
- Anything bound to the menu's own window is bound from inside the menu's React tree, so the remount re-binds it. The
entry itself binds nothing there — the glyph is static and reads no state at all — but a page the panel mounts does:
utils/qamExpansion.ts's stylesheet andMutationObserver,utils/entryFocus.ts's focus listeners,bigpicture/layout/WidePage.tsx'sResizeObserver, andbigpicture/layout/ScrollRegion.tsx, which reads the view per event and retains nothing. Each of the four sits inside an effect or an event handler of a component the menu mounts, which is what makes the remount re-bind it; one held at module scope would work until the first Gaming-Mode-to-Desktop switch and then do nothing, silently.
An already-mounted menu is adopted rather than waited for. React flattens the renderer's memo wrapper at mount and
carries the resolved type on the fiber, so swapping the module's export afterwards reaches nothing already on screen.
That is React's own behaviour rather than @decky/ui's, which supplies the patcher and not the flattening, and #1897
confirmed the consequence on the device — the patched renderer's own handler was never entered. Without the adoption the
entry would arrive at the menu's next remount, which follows from what a remount does rather than from an observation.
Decky adopts for the same reason.
The glyph¶
The mark reduced for the strip: no disc, one tone, the sync ring levelled and thickened, and the body as the button bars drawn as solid capsules. The strip carries nothing but single-tone free-standing glyphs — read off a screenshot rather than measured on the device — a bell, friends, a cog, a bolt, a note, a question mark, and Decky's plug — every one of them a bare silhouette, so a disc behind ours would be the only backing plate in the row.
Nothing marks the four button positions, and that is arithmetic rather than taste. They shipped punched out of the bars as holes, on the reasoning that took the disc away: with one tone a filled dot has nothing to be filled with that the bar is not already, so it disappears into the bar it sits on. At strip size the bar cannot pay for the cut. The glyph asks for 28 px across a 200-unit square, so one unit is 0.14 px: a bar 31.38 units wide is 4.39 px, a hole at radius 12 is 3.36 px across, and what is left of the bar either side of one is 0.52 px. Half a pixel cannot draw a bar, so the holes ate the body instead of marking the buttons, and the bars are solid.
The arc and its arrowhead are thickened by 1.3, which is the same size read the other way: at the 28 px the glyph asks for, the mark's own 15.5 stroke lands at 2.17 px, the thinnest thing in a row of solid silhouettes. The mark's own weight reads too fine there, and the 1.3 is what the arc carries instead.
It is generated, by scripts/logo/tabicon.py through build.py --tab-icon, into frontend/src/qam/tabIconArt.ts;
the geometry comes from the mark's own drawing routines, so the two cannot drift. Its two departures from the mark's
geometry, and why, are at tabicon.STRIP_GEOMETRY.
Nothing about it moves, and that is a measurement rather than a taste. It shipped with a ring that turned while a sync ran and a body that folded while the entry was active. Read over CDP on the QuickAccess target in 6-second windows, with only the fold running:
| fold running | animation off | |
|---|---|---|
TaskDuration |
1.726 s | 0.0077 s |
LayoutCount |
720 | 1 |
RecalcStyleCount |
720 | 1 |
LayoutDuration |
0.7626 s | 0.001 s |
That is roughly 29% of one core for as long as the menu is open, and a full layout plus style recalc 120 times a second
— twice a frame at 60 Hz — because animating a path's d forces layout every frame. The ring was not running in
either reading, so nothing here is a measurement of it: starting a sync run was not possible in that session. It was
an <animateTransform type="rotate"> on a <g> rather than an animation of d, so the mechanism above does not reach
it and what it would have added is simply unknown. On a handheld, that is not a trade a decoration rendering at 24 px
gets to make. TabIcon.test.tsx fails if any of SMIL's animation elements comes back — it can see nothing else, and
motion driven from CSS or a rAF loop would pass it — because the cost is invisible to every other check here.
Two things about it are unmeasured, and neither is guessed at:
- Whether it lands at the 28 px it asks for. It asks in em rather than in pixels so it scales with Steam's UI, and
the
1.633emit asks with is scaled off a measurement rather than arithmetic: the1.4emit used to carry drew a box of 24 px, which is 17.14 px to the em. That is not the 16 px parentfont-sizeread beside it, so plain CSS em resolution cannot be the whole story, and what sits between the two was never established — nor was it established that the strip takes the length it is handed rather than clamping it. The 1.633 needs only the ratio and holds either way. - Whether it takes the colour of the selected tab. It asks for
currentColor; what that inherits in the strip is not established here.
The boundary¶
The panel renders inside PanelErrorBoundary, which has exactly one action. Without a boundary a throw inside the panel
unmounts the tree it was rendered in — Steam's Quick Access view, not ours — so the fault would cost the reader every
tab in the strip rather than one. @decky/ui's own ErrorBoundary is not a component it defines: the module is one
findModuleExport sweep for a class of Steam's, so both bundles would resolve the same one. This boundary is Tender's
because a search into Steam's bundle can stop matching on a client update, and a boundary is the one component whose
absence is discovered by the fault it was there to catch.
Reload clears the caught error, and that is the whole of the rebuild: React has already unmounted the subtree by the time the button exists, so rendering it again mounts a new tree with no state carried over. It deliberately does not re-evaluate the bundle (the injector's job, and nothing in this tree can ask for it) nor re-run the plugin factory, which would install a second copy of every listener and patch it registers. What survives is the panel's module-level state — the open page, the sync progress, the caches — so a reader whose Settings page threw comes back to Settings.
Two widths¶
Every page is either narrow (348 px, 300 px of content) or wide (854 px, 806 px of content). The width belongs to the page: a page is wide or it is not, and no view inside a page changes it. Main and Downloads are narrow; Sync, Library, Settings and Data Management are wide.
On the Deck, wide means full screen. The Big Picture viewport is 854 × 534 CSS px at devicePixelRatio 1.5 (1280 ×
800 physical), so the narrow panel covers 41 % of the width and the wide one covers all of it — there is no library left
beside a wide page. A desktop Big Picture window is wider and shows one, which is why width judgements are made under
mise run dev:ui-scale deck and nowhere else.
That 534 is the internal display and nothing more — do not size against it. The dev loop's windowed Big Picture is a
different viewport on the same machine: measured through CEF during the third device round, the QAM view reported
innerHeight 764 at devicePixelRatio 1.71 on a 1496 × 842 screen, where the width still came out at 855. Both
numbers are real measurements of different configurations, and code that assumes either is wrong on the other — which is
why the frame measures the space it is actually given rather than deriving it from a recorded viewport.
How a page gets wide, measured on the device rather than read from documentation:
- Steam's main window holds the QAM in a sliding container: an absolutely positioned element as wide as the viewport and
854 × 454 CSS px on the Deck — the 80 px above it is Steam's top bar — anchored right and pushed off-screen by
transform: translateX(506px), so 348 px stay visible. Steam'sExpandedclass setstranslateX(0). Its class names are hashed and there is noViewPlaceholderto 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 formessageevents on the SharedJSContext window — the window plugin code runs in. A wide page posts{ message: "QamFriendsExpanded" }towindowon mount and{ message: "QamFriendsHidden" }when it lets go. The target origin is alwayswindow.origin, which addresses the message to that window and always matches it. A well-formed target origin that does not match is checked at delivery and the message is discarded in silence, so a literal one would leave the panel simply never widening. - Every tab's content panel carries
max-width: 300px; only Steam's Friends panel lifts it. A wide page injects one stylesheet whose:has()rule lifts the cap for a marker class on the plugin's own subtree. Class names come fromquickAccessMenuClasses, which can beundefined;[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.TabGroupPanelsits 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 ownoutline: outset #fff 2pxfor a disabled button under.gpfocus, which Steam's stylesheet omits. A wide page keeps its buttons rendered-and-disabled rather than hidden, so the stick lands on them and the focus ring would otherwise disappear for that row; it rides in this sheet because the sheet is already scoped to the wide root and a second injector for one selector would be a second thing to clear. - Result: the visible panel goes from 348 px to 854 px and the tab panel from 300 px to 806 px (854 minus the 48 px tab
rail). The QAM browser view itself is 854 px wide in both states, so only the sliding container's geometry, read
through
findSP(), proves an expansion.
The flag is Steam's and global, so the page that set it clears it: on unmount (navigation away, plugin closed), when the
tab the page sits in stops being the active QAM tab (the ActiveTab class on the panel's parent — a tab switch is a
class change, not an unmount), and when the QAM closes (useQuickAccessVisible). It no longer clears from onDismount:
that was Decky's teardown hook and nothing calls it now. Which tab that is is never asked: the page walks up to the
panel around it and reads the class off that panel's parent, so the same code answers for Tender's entry and for
Decky's.
Both of those questions proceed when they cannot be answered, and each one costs at worst an expansion the other
paths still clear — the alternative default leaves a wide page permanently narrow with nothing saying why. The tab
question cannot be asked when no panel is found around the page, or when the class probe came back undefined. The
visibility question cannot be asked when Steam's focus controller holds no navigation trees, which is the desktop client
until Big Picture has been opened: the menu's window is reached through the QuickAccess-NA tree, so with no trees
there is nothing to read a document's visibility off. It is asked through utils/quickAccessVisible.ts, Tender's own
copy of @decky/ui's hook, because upstream's throws on that reading instead of answering — why a copy rather than a
guard at the call site is written out at that module, along with what else it changes besides the guard.
Steam moves the same flag on its own, in both directions, and neither is a bug in the plugin. OpenQuickAccessMenu
clears it (SetQAMFriendsChatExpanded(false)) on every QAM tab change away from Friends, which is a second net under
the plugin's own ActiveTab observer; and the Friends tab's list expands it from onFocusWithin, so Friends goes wide
the moment gamepad focus enters it. A Friends panel that widens after a wide page closed is Steam doing that. Both are
in chunk~2dcc5aaf7.js in Steam's own bundle, where the receiver is also visible: OnMessage on the FriendsUI store
sets m_bQamFriendsExpanded from exactly the two messages the plugin sends, and Steam's own senders post with the
literal "https://steamloopback.host" — which is what window.origin is in the SharedJSContext.
Steam's tabbed page fills its parent instead of growing, and nothing in the QAM chain provides a height. A wide page
therefore measures the space left below its header and takes that as its height; its regions scroll inside it. A
min-height is not enough — it clips. What is left after whatever chrome sits above the page and the frame's
Back-and-title row is the panel's clientHeight less the body's offset within it, so it follows the view rather than
any recorded number: measured through CEF on the dev window's 764 px view, with the change below applied to the running
panel, that is a body of 660 px ending flush with the panel's box. A tabbed page spends 58 px of it on Steam's tab
row, which is drawn over the top of the content pane rather than above it.
That measurement has to be free of the scrolling panel's own offset, and only a layout-relative one is: the body's
position inside the scroller's content — its viewport top minus the scroller's, plus the scroller's scrollTop —
subtracted from the scroller's clientHeight. Every viewport-relative form fails, because the panel's own
scrollTop moves the body's rect and leaves the panel's own rect where it is. That made the measurement feed itself,
and the loop has no fixed point: a body measured part-way down comes out that much too tall, the panel then has that
much more to scroll, and nothing re-measures. It reached a device as a page that scrolled as one piece, tab row and all
— 1245 px of body inside a 750 px panel.
Measured live in the QAM over the mounted page, at panel offsets 0 / 200 / 500 / 634 px, window.innerHeight - top
answers 648 / 848 / 1148 / 1283 — and so does scroller.getBoundingClientRect().bottom - top, because the panel's rect
bottom is 764.3 against an innerHeight of 764. Bounding to the panel instead of the window is therefore not the
fix; it changes no number at any offset. The layout-relative form answers 648 at all four. Those four come from an
earlier round, before the Back row and the title shared a line, so the body sat at offset 102 where it now sits at 89.8
— which is why they are 12 short of the 660 above rather than the height of the gap this frame no longer keeps. What the
passage is about survives the difference: the same form answers the same number at every offset. What makes a non-zero
offset reachable at all is that QAMPanel resets the panel's scroll inside a requestAnimationFrame, a frame after the
page's own layout effect has already measured.
The height alone is not the whole fit, because the frame's own ancestors can hang below it. The chain this was
written against was Decky Loader's: it wrapped a plugin's content in a box that sat 34 px below the panel top — its
plugin title — and took height: 100% of a parent it was already inset within, so its bottom landed 50 px past that
parent's. Behind Tender's own Quick Access entry there is no such wrapper, and zero is a reading the routine below was
written to survive rather than a case it had to be taught. Nothing of ours is painted in those 50 px, but the panel
scrolls by them, and a scroll of that size takes the frame's Back row off the top. WidePage measures the overhang
(ancestorOverhang, summed over each ancestor up to the scroller) and cancels it with a negative bottom margin on the
page root rather than taking it out of the height: a margin changes what the box claims after itself, not where it
paints, so the ancestors end where the scroller's box does and nothing on the page moves. The height and that pull-up
are one measured value applied in one render, because each half alone is measurably useless: applied live to the
running panel, the height without the margin overflows the scroller (scrollHeight 800 against a clientHeight of 750
— 50 px of scroll, which is what takes the Back row off the top), and the margin without the height moves nothing a
reader sees, the page still ending on the same line with the same band under it.
What makes the pull-up cancel anything is a structural assumption, and it is worth stating on its own, because the overhang is measured against a PARENT and the margin is applied to our root. The boxes between our root and the scroller are content-sized: the wrapper's bottom is our body's plus its own inset, at every body height. So pulling our root's margin box up carries those bottoms up with it, and the chain stops claiming exactly the overhang the margin names. The assumption is a SHAPE rather than a value — every number is re-measured, so a Decky release that merely overhangs by a different amount is already handled — and it holds because the overhang is the wrapper's own inset and padding rather than anything derived from what we put inside. It was checked at several body heights in two panel geometries.
If it ever stops holding, this is what it looks like. A wrapper pinned to a height of its own — a future Decky or Steam nesting the plugin differently — would not follow our body up: growing the body would overflow the wrapper instead of the wrapper's parent, the margin would cancel nothing that was in the way, and the panel would scroll again, which the reader meets as the Back row leaving the top. The check is one reading: the lowest ancestor bottom in the chain should equal the body's own with the margin applied, and sit an overhang below it without.
Buying the room instead of cancelling it is what the first cut did, and it cost the bottom of every wide page: the body
gave up 50 px of its own so the wrapper's empty 50 would fit, which left the wrapper ending 12 px short of the panel's
box — the gap the frame kept — and our content a further 50 px above that — an empty band across Settings, Library and
Sync, with content that would have fitted clipped out of the difference. Measured live — on Settings when it was
reported, and again on Library, which answers the same because the scroller is the panel's rather than the page's — the
scroller's box ran to y=764.3 (clientHeight 750, unscrollable) while the page root ended at y=702.
Two further gaps stood under a wide page after that, and neither was earned. The first was ours: the frame kept 12
px of breathing room off its own measurement, which is why a page ended at y=752 inside a panel whose box runs to 764.3.
A QAM panel of Steam's own, measured in the same document, runs its content to its box with a gap of 0, so the constant
went rather than being set to zero — room under a page belongs to that page's layout, where it can be seen and adjusted,
not to a number the frame takes off every page's height. The second is Steam's, and it is cancelled by an override
rather than absorbed: a tabbed page's content scroller carries padding-bottom: 40px (rule
._1X4dtbZ_AMX_DXT-SGiK01), and that scroller sits inside the box WidePage measured and handed the tab as its height
— so on Library the content stopped at y=712 inside a body of ours that ran to 752, a 40 px reserve taken out of a
height the frame had already paid for. An untabbed page renders no such scroller and keeps none of it, which is why
Library read as having a deeper band than Settings and Sync — the difference that was reported and could not be
explained. The injected sheet zeroes the padding for a wide page of ours (qamExpansion.ts, written against the
readable _TabContentsScroll rather than the hashed class, and degrading to Steam's padding if either name goes). With
both gaps gone, Library's body measured 660 px and its content ran to y=764.0 against a panel box of 764.3,
scrollHeight still equal to clientHeight. What the reader gets is 52 px on the tab's own scrolling region — the box
the rows live in — measured on Library at 161.8 → 712 before and 161.8 → 764 after, a clientHeight of 550 against 602
over the same 1570 px of content.
No test here can see any of that. happy-dom performs no layout, so WidePage.test.tsx pins the arithmetic — the
height, and that the pull-up equals whatever overhang was measured — and qamExpansion.test.tsx pins that the override
is in the sheet and scoped to our root. That the panel does not scroll, and that the band is gone, is a device
observation each time.
A region scrolls the way the rest of the QAM scrolls: by moving focus. Every scrolling region goes through
ScrollRegion, which renders Steam's plain ScrollPanel — the container the QAM's own tab panel is built from, and the
one Steam's tabbed page wraps each tab's content in. It is an overflow-y: auto box that takes no focus of its own, so
the rows inside it take focus directly and Steam scrolls the focused row into view.
Every row a reader must be able to reach is a focusable row. A toggle, a button, or — where a table row carries no
action of its own, so the reader can still walk the table — a Focusable with an onActivate handler. The handler is
what makes an action-less ROW a stop, and a region of nothing but text declares focusableIfEmpty instead — the cleanup
modal's details region is the one that does; a bare Focusable is a container that passes focus on to its children
rather than taking it. Plain text that only accompanies a row, a hint under a group, scrolls with its neighbours and
need not be reachable itself. This is what focus-driven scrolling costs: content nobody can focus cannot be scrolled to.
The repository ESLint rule tender/qam-focusable-row checks one syntactic slice of that rule in the QAM modules listed
above. A Focusable imported from @decky/ui must declare a stop of its own, contain a static focus stop, or contain a
child/spread whose focusability cannot be established statically.
What declares a stop is what Steam's nav node reads. The base panel forwards focusable and focusableIfEmpty into
that node's options, and promotes a node carrying onActivate or onOKButton to focusable — but only where neither
option was supplied; onCancelButton it wires up while promoting nothing. The node's own answer is GetFocusable(),
which reads four options — focusable, focusableIfEmpty, childFocusDisabled, fnCanTakeFocus — and answers
"self" for a truthy focusable whatever the node holds, and for focusableIfEmpty only while childFocusDisabled is
set or the node holds no child nav node at all. Child nav nodes are what other Focusables and Steam's own nav
components mount, focusable or not, so a node holding a non-focusable one answers "children" and is no stop even with
focusableIfEmpty. That is what makes it the right declaration for a region of nothing but text — it steps aside by
itself the day a control is added inside — and what the cleanup modal's details region uses. FocusableProps declares
neither option, so both arrive through a spread or a cast. The panel's other text-only stops — SessionBudgetBanner and
its peers — carry a no-op activate handler instead, which is the cheaper spelling and keeps the stop even once a control
lands inside them.
The matcher asks two questions, and only one of them of a plain div. Those props, of the row and of a Focusable
among its descendants, since a host element renders no nav node and they are inert on one. And DOM focusability, of a
DESCENDANT only: a tabIndex, a button, input, select or textarea, an a with an href. So a row whose own
tabIndex is its only affordance is reported — that attribute reaches the rendered element rather than the nav node,
and no D-pad move from a neighbour reaches it — while the same attribute on something inside a row leaves the row alone,
because a container holding what the browser can focus is not one this rule can call empty. An explicit {false}
declares nothing; every other value is taken at its word.
That attribute is Steam's to write, and this is its one statement here. The base panel writes it wherever a nav
option is set — including where onActivate or onOKButton promoted the node to focusable — and never where the tree
navigates virtually, so a container that declares nothing carries none. Everything below that reads div[tabindex="0"]
as a focus stop is reading that symptom rather than what caused it, and says so by pointing here instead of restating
the condition.
This catches an action-less row written as a static wrapper while leaving structural containers and opaque children
alone. What it cannot read, it passes: one opaque child anywhere among a row's children — a &&, a call, a bare
identifier — passes the whole row, which is how the cleanup modal's details region stood unreachable with the gate
green, and why a row's reachability is a review question and not a gate one. It does not check focus order, runtime
reachability, edge revelation, scrolling geometry, or controller behaviour; those parts remain a device and review
invariant.
The one place a page cannot buy its way out of that is the content OUTSIDE its focusable rows, and the frame handles
it rather than each page: a heading, a counts line or a column header sitting over the topmost row, and a legend, a
total or a hint under the last one, are not focusable and have no neighbour to ride along with, so once the reader has
scrolled past them Steam has no reason to bring them back — it scrolls only far enough to show the focused element.
ScrollRegion therefore scrolls itself to the top when focus reaches the first stop in it, and to its end when focus
reaches the last. Every region built with ScrollRegion gets that, which is not the same as every region on every
wide page: a tabbed page's own tab content sits in Steam's ScrollingTab, so a tab that does not build its own regions
is not covered — none does today, since both of Library's tabs are list and detail. Two properties make it safe rather
than a fight with Steam's own scrolling. The triggers are "nothing focusable is above me" and "nothing focusable
is below me", never "I am the first match" or "the last" — a container Focusable that declares a stop, or is
promoted to one by an activate handler, renders tabindex="0" of its own and precedes in document order every row it
wraps, so it is never the last match and a wrapped row is never the first, and an equality test against either end would
silently never fire wherever a page wraps its rows, which ListDetail does for every row. So the first rule discounts
the focused element's own ancestors and the second its own descendants. And each acts only where the focused element
still fits in the region at the offset it would move to: where the content beyond it is taller than the region there is
no offset showing both, Steam would scroll the element straight back, so nothing is done at all. A stop at both ends at
once reveals the top where the top fits, and otherwise the end where that fits.
The set of shapes it counts as a focus stop is measured in the running QAM, not assumed: Steam's own components render
div[tabindex="0"] — the symptom Steam writes for a declared stop, never what makes one (above) — and a DialogButton
is a native button carrying no tabindex attribute at all.
A region also keeps the wheel to itself. Its overscroll-behavior is contain, because all three nested scrollers
here — the region, Steam's ScrollingTab above it, the QAM panel above that — compute auto by default, so a mouse
that reached the end of one went on to scroll the panel and took the frame's Back row off the top with it. A controller
never showed it: Steam scrolls a region by moving focus, not by wheel events. The property names no axis of overflow,
so it cannot undo the sideways clipping the bounds deliberately leave to Steam.
A list-and-detail page's detail region is keyed on the selection, so choosing another entry mounts a fresh region and its detail opens at its own top rather than at the offset the previous one was left at. A key rather than a ref, because the panel is reached through a webpack probe and nothing establishes that it forwards one; and it is safe because focus is in the list when the selection changes — that is what changed it — so nothing focused is unmounted.
ScrollPanelGroup — the sibling that binds gamepad direction to scrolling, and would carry unfocusable content — was
tried on the device and rejected. It is focusable and its OK button focuses its first visible child, so each region
became a focus stop of its own: the whole list outlined as one block, A to step into it, and only then rows taking
focus. Steam's own QAM does not behave that way.
Both of a list-and-detail page's regions are ScrollRegions, and so is the frame's body when the page has no tabs and
does not say it owns its regions. A tabbed body gets none from the frame, and neither does one whose page passes
ownRegions: see "Building blocks → Tabs" for whose job it is instead.
Pages¶
| Page | Width | Holds | Today |
|---|---|---|---|
| Main | 348 | notices, status, the conditional slot, the download summary, the menu | as described |
| Sync | 854 | preview as a table, the run as a plan, Skip preview, Force Full Sync, Steam memory, session budget, last runs | as described; the import choice (#1364) is the one thing still to come |
| Library | 854 | Platforms as list and detail (sync, core, BIOS files, removal); Collections as list and detail — the kinds, each kind's collections as a table | as described |
| Settings | 854 | six sections, list and detail | as described; RetroAchievements has no sign-in to hold yet (#1627) |
| Data Management | 854 | six populations as list and detail — what this device holds, and what can be taken back | as described |
| Downloads | 348 | the queue with its controls | unchanged |
Page is "main" | "sync" | "library" | "settings" | "data" | "downloads". System is gone — its core picker and
BIOS files are in Library › Platforms, and the value, the router branch and the menu entry left with it.
The Sync page opens from the menu, from the conditional slot while there is something in it, and from Open Sync on
the paused-run notice; Downloads opens from View All in the download summary, which is shown only while the queue is
not empty. A notice can carry a door of its own, and three of them name a Settings SECTION rather than the page — Open
Controller, Open Connections and Open Updates — but a notice and the slot are both there only while their
condition is, so the menu is the navigation a reader can go looking for. A target is a page id, or
{ page: "settings", section } for those three (frontend/src/types/navigation.ts); the section rides on the page
rather than beside it, so no target can pair a section with a page that has none, and the router
(frontend/src/index.tsx) stays the only thing that decides what is mounted. A navigation naming no section opens
Settings on its first, exactly as the menu's own entry does. Every page but Main opens with a Back chip, which
returns to Main. The chip shares its line with the page title — one row, not the three a full-width button plus a title
line used to cost, which on the Deck's body is most of what a detail pane has to spend. Back is also on B, and the
binding lives in the panel's router (frontend/src/index.tsx) rather than on a page: one Focusable with
onCancelButton wraps the mounted content only while page is not main, so every sub-page — wide and narrow —
answers B from wherever focus sits, and Main answers nothing, so B does on Main whatever the menu holding the panel does
with it. That condition is what makes taking B safe: the escape route is never removed, it is exactly as far away as the
user walked in, and the last press is never swallowed. Steam already prints "B ZURÜCK" in its footer legend, which this
makes true rather than misleading, so no legend entry of ours is needed. The chip stays as the discoverable half and as
the mouse path, and it carries Steam's own B glyph — drawn for the controller in the user's hands, so it is ○ on a
PlayStation pad and the swapped face button under a Nintendo layout. @decky/ui does not re-export that component, so
frontend/src/utils/deckyUiInternals.ts reaches it by a module probe and types it as possibly absent; the chip falls
back to its chevron the day the probe misses. The button number it passes is Steam's own action-button enum
(A=0, B=1, X=2, Y=3), not @decky/ui's GamepadButton, where 1 is A — the two disagree on every value, and the
wrong one draws the wrong glyph without failing.
A tabbed wide page has to get out of the way for that to work. Steam's tabbed page renders its content pane as
onCancelButton: !cancelSkipTabHeader && <focus the tab row> (chunk~2dcc5aaf7.js), so without the flag the first B
inside a tab is spent moving focus to the tab row and never reaches the router. WidePage passes cancelSkipTabHeader
— Steam's own prop, which it uses in its controller-configurator dialogs, and which upstream's TabsProps predates;
frontend/src/utils/deckyUiInternals.ts types it. After a navigation the router scrolls the panel to the top, and
gamepad focus is placed where the page opens — by the router for a narrow page, at the area the page declared or at its
first stop where it declared none, and by the frame itself for a wide one, which says so on its root so the router
leaves it alone (see "Building blocks → Tabs"). The module-level currentPage survives a QAM remount, so reopening the
QAM lands on the page that was open, and a wide page re-expands on mount.
Building blocks¶
Tabs¶
Steam's tabbed page, switched with L1/R1, for two to four peer views of one page. Wide pages only: at 300 px the bumper glyphs overlap the labels, which is why the Library page's tab bar is hand-rolled today. Only Library has tabs.
Entry focus lands in the content — the active tab's, so the list of a list-and-detail page — and the bumper glyphs follow, because Steam draws them only while gamepad focus is within the tabbed page. Back stays reachable by moving up.
Entry focus belongs to the frame, on every wide page. WidePage marks its root as placing its own, so the panel's
router leaves the page alone rather than placing focus of its own, which would land on the Back chip above the body.
Where Steam's tabbed page renders, its autoFocusContents does the placing; everywhere else — an untabbed page, and a
tabbed one whose Tabs probe missed, which the start-up check does not let a mounted panel reach (Tabs costs the
panel, frontend/src/boot/steamModules.ts) — the frame places focus inside the body itself, by the router's own rule
and on the same 50 ms delay: the area the body declared, or its first stop where it declared none. The delay is there
because Steam's navigation resolves a focus pointer it retained across the page swap after the mount. Opening a page is
the frame's moment and its only one: a page whose body changes while it stays open answers for that swap itself, by the
same rule and under a condition of its own — the Sync page's left column is the one that does.
The stop it picks is the first enabled focus stop in document order that contains no focus stop at all — one rule,
both widths. Document order rather than "the first button", because a page's first button is not its first row, and it
is not that on either width: on a wide list-and-detail page whose list rows carry no control, the first button in the
body is in the DETAIL pane, so a button-first rule would open the page inside the detail and move as the detail's
content changed. Innermost, because a container Focusable that declares a stop — or carries an activate handler, which
promotes it to one — has a tabindex="0" of its own and precedes every row it wraps, so the first match would be the
container and the reader would start a step away from the row. Enabled, because a page opening on a dead control says
nothing about where the reader is — the reveal rules in ScrollRegion read the same shapes and do NOT skip a disabled
control, since focus still lands on one.
The two halves read different selectors, and the difference is load-bearing. A candidate has to be enabled, but a container is skipped for holding a stop of ANY kind: a button row whose every button is disabled is still a container Steam does not stop on, so treating it as the innermost candidate would put the focus ring on it and take it off the next real row. The test cannot tell that row from one carrying an activate handler, so it skips both; a row whose only inner stop is disabled is stepped over, which no body's first column produces today. Where that leaves no candidate — no enabled stop that is free of stops inside it — nothing is placed and the page keeps whatever Steam's retained pointer resolves to.
Every page takes the same rule, unless it names somewhere better. A page marks the area entry focus belongs in
(ENTRY_STOP_ATTR) and whichever placer opens it — the router, or the frame — picks the stop inside that area with the
same rule, so what a declaration changes is WHERE the rule is applied and never which element it picks. Two things
declare, for two different reasons. Main declares on the menu's Sync entry: its three status rows act on nothing,
so opening on the first of them — Connection, which is where the panel opened before — spends the reader's first press
on a move to what they came for. The declaration is what makes that stable. Main's first BUTTON is not the menu whenever
a notice carrying an action is on screen, so a button-first rule would open the panel wherever the day's conditions put
one; that rule was tried and dropped for the same reason, back when its argument was that a narrow page is one column of
Steam's own full-width rows where the first button IS the first row — true of Main only while Main had a Sync button
near the top.
A list-and-detail page declares on its SELECTED row, and there the declaration is not a preference about where to
land but what makes the page keep the state it was opened with: focus selects on that layout, so entry focus landing on
the first row selects the first row. Settings is opened on a named section by three of Main's notices, and before the
row was declared each of those jumps mounted the right section and then had it overwritten about 50 ms later — Open
Controller landed on Connections. A list opened with no section named loses nothing: it either selects its own first
row, which is what the fallback would have picked, or selects nothing and so declares nothing (the Library page's
platforms, which additionally are tabbed, so Steam places that focus and the frame places none). Library › Collections
opens on its first row, Collections, and declares it anyway — but it is tabbed: wherever the panel mounts, Steam's
tabbed page places the focus and the mark goes unread. The frame reads it only on its missed-Tabs branch, which the
start-up check does not let a panel reach (Tabs costs the panel).
Downloads is unmoved and declares nothing: it leads with its Back button, which is both the first stop and the first
button, so the router's default already opens it there. Data Management needs no declaration of its own — it is a
wide page, so the frame places entry focus in the body by the rule above, and on its list that is the first row.
Whatever the rule, the root it searches is the plugin's own content and nothing above it — under Decky, its panel title
and the back arrow beside it are rendered outside that box, 34 px above it (the same inset whose bottom WidePage's
ancestorOverhang measures); behind Tender's own entry there is no such chrome at all, because Steam's tab group
renders the panel directly — so no rule here could reach anyone else's. The declaration, the finder, the shared set of
shapes and the .focus() + gpfocus pair are frontend/src/utils/entryFocus.ts. It is a second attribute rather than
a second use of the wide frame's OWNS_ENTRY_FOCUS_ATTR because the two answer different questions: that one says WHO
places entry focus — it tells the router to place none, because the frame places its own — and this one says WHERE, for
whichever of them places it. So a wide page carries both, Settings being one: the root says "I place my own" and the
list's selected row says "here". The router never reaches the second, because it looks for OWNS_ENTRY_FOCUS_ATTR first
and, finding it, sets no timer at all.
A tab's content is the page's business, not the frame's. The frame wraps an untabbed body in a ScrollRegion and a
tabbed one in nothing: Steam's tabbed page already wraps each tab's content in this same plain scroll panel, so a region
from the frame would only nest a second scroller around it. Rows of one column therefore scroll in a tab with nothing
added. A page that needs more than that one scroller — a list and a detail scrolling independently side by side — builds
its regions with ScrollRegion itself, which is what Library's tabs do, and an untabbed page that does the same says so
with ownRegions so the frame wraps its body in none either.
Scrolling a region without moving focus¶
A region scrolls by focus, and that is the whole of it for a page a reader walks. A page whose content advances on its
own has no focus move to ride on — the sync run walks its own unit rows — so it scrolls the region itself, and names the
region (ScrollRegion's testId) to find it. A name rather than a ref: Steam's scroll panel is reached through a
webpack probe and nothing establishes that it forwards one, while the attribute lands on the element either way. Such a
scroll is clamped to the region's own ends, so a first or last row is left where it sits rather than centred past the
start or the end of the list, and a region whose content already fits is not scrolled at all.
Room for the focus ring¶
Every ScrollRegion keeps 4 px of room inside its own box for Steam's focus ring (FOCUS_RING_REACH in
bigpicture/layout/ScrollRegion.tsx). On the branch where the region is Steam's scroll panel, the ring is not drawn on
the focused element but over it, from the panel's own focus-ring root inside the region. Measured through CEF in the dev
window (855 px wide), in the list column of a list-and-detail page before the room existed: the region's first child is
that root — an absolutely positioned element at the region's top left, which Steam's class map names FocusRingRoot —
and the size Steam takes for a row's ring (GetBoundingRectForFocusRing on its nav node) is the row's own box, which
there was the column's full width and, for the first row, started at the region's top. Read from Steam's stylesheet
rather than measured: the ring is the FocusRing class of the same module that exports FocusRingRoot (in
css/chunk~2dcc5aaf7.css for the client this was read on — the chunk name changes between Steam builds, the module's
two class names are how to find it again), a 2 px outline at a 2 px offset, so it reaches 4 px past every edge of what
is focused, and the region clips it at its own box. The ring itself could not be observed through CEF — Steam draws it
only in the active navigation context. So without the room, a row spanning its column loses both side edges of its ring,
and the first and last rows their top and bottom edges too.
The room is the region's own padding, not something inside it and not something on the outside of it. Outside is
ruled out by the region's sideways clip, which is deliberate (ScrollRegion). Inside — a padded element between the
region and its content — is ruled out by a child that fills its region with height: 100%, as the Sync page's run body
does: a percentage height inside an element whose own height is auto is auto too, and the run body's unit list would
lose the bounded height it scrolls inside. The padding is declared with box-sizing: border-box, because Steam's own
panels compute content-box (measured on the Quick Access tab panels), under which it would be added to the full height
the region is given and overflow the parent. Three things the padding could have disturbed, measured in the Quick Access
document of the same client — the first on Steam's own tab panels, the other two on a probe element standing in for a
region, a 100 px scroller padded 4 px under border-box, not on a ScrollRegion: the ring root does not move with the
padding, because Steam's stylesheet places it with top: 0; left: 0 — a tab panel padded 16 px at the top holds its
root at its own top left; a child of height: 100% resolved against the probe's content box (92 px); and the probe's
block-end padding counted in its scrollHeight (308 px over 300 px of content), both as a block and as a column flex
container — which is what lets scrolling to the end show the last row's bottom edge, and what revealBottom reads.
The fallback branch keeps the room too, for layout rather than for a ring. Where the panel probe missed, the region
is a plain Focusable with no focus-ring root of its own, so its box is not what clips a ring there, and where that
ring is drawn and clipped has not been looked at. The room stays so that a page's content lays out the same on both
branches — and one page depends on it: the Sync page's unit list stands in its column's room with a negative side margin
(below), and the fallback's overflow: auto covers both axes, so a column without the room would scroll that list 4 px
sideways. That consequence is read from the code, not observed.
Every region built with ScrollRegion gets it, and on the wide pages every region that holds a full-width focus stop
is one: both columns of a list-and-detail page (Settings, Data Management, Library › Platforms and Collections), both
columns of the Sync page — the preview table's rows in the left one, the option, memory and last-run rows in the
controls column — the run view's own unit list, and the frame's region around an untabbed body that builds none of its
own, which no page uses today. One region sits inside another: the unit list is a region within the Sync page's run
column, and on the panel branch its rows' rings are drawn from its own ring root and clipped at its own box, so the room
it needs is its own. It stands in its column's room with a negative side margin of the same 4 px, so the unit table
starts where the section title over it does rather than a second room further in. What the room moves everywhere else is
the same 4 px on every side of a column's content, which moves a column's rows, headers, titles and button rows
together; a column drawn at a fixed width (the list's 264 px, the Sync controls' 270 px) keeps that width for the box
and gives its content 8 px less. Entry focus reads the DOM for stops and is untouched by it.
What is not a ScrollRegion gets none. A tab that builds no region of its own sits in Steam's ScrollingTab (no
tab does today), and the narrow pages sit in the Quick Access panel's own scroller. The removed-games cleanup dialog has
two scrollers of its own — the dialog body and its details region (bigpicture/RemovedGamesCleanup.tsx). Whether a
full-width row in any of these loses the edges of its ring has not been looked at.
Columns¶
One row of side-by-side scrolling regions, which is what every wide page whose content is more than one column is built
from: a Focusable the stick crosses horizontally, and a ScrollRegion per column. A column names a fixed width or
takes what is left, and may name a region key — joined to the column's id to form the React key its region carries —
so that changing it remounts that one column and its content opens at its own top rather than at the offset the previous
content was left at. A column that names none keeps one constant key and is never remounted. A key rather than a ref
that scrolls the region back: Steam's scroll panel is reached through a webpack probe and nothing establishes that it
forwards one.
List and detail is Columns with two columns. The Sync page's table beside its controls column is the next.
The pane primitives¶
The pieces a detail pane is built from, in frontend/src/bigpicture/layout/pane.tsx so that the next pane is written
against the same scale rather than a second literal for the same size: the 11 px every secondary line is set in, the
verdict palette, the two button shapes (FLAT_BUTTON for a button sharing a row, ROW_BUTTON for a table row's action
column), a section title, a muted line, a row of buttons, and the two lines that report an action — the status line
bound to the entry and the group it belongs under, and the sentence saying which other entry is working while this
pane's buttons are disabled.
List and detail¶
The list takes about a third of the width (264 px in the prototype), the detail the rest. Focus selects: moving through the list changes the detail at once, as Steam's own settings do. A list row may carry a toggle; A operates it, never the selection. Both regions scroll independently inside the page's measured height, and both scroll by moving focus — so a detail pane is built from focusable rows, not from paragraphs.
A list whose rows carry no control of their own — a label and nothing else, which is what Settings and Data Management
have — asks for selectOnActivate, and every row wrapper takes an activate handler that selects it. That handler is
what makes the wrapper a focus stop rather than a container, so without it those rows are unreachable and the list
cannot be scrolled. It is off by default, because a row that does carry a control must leave A to it. A row may name
its own answer, overriding the list's, for a list whose rows differ — Library › Collections, where two rows carry a
switch among rows that carry nothing. A row that names none follows the list.
A list that is grouped or sorted by state computes its order when the page mounts and keeps it while the page is open, so toggling a row does not move it out from under the focus. The next mount shows the new order.
A control that acts on the whole list — Enable all, Disable all — goes in the layout's listHeader, above the first row
and inside the same scrolling region. It sits outside every row on purpose: focus moving onto it must not report a
selection, because a page may do real work on one.
It spans exactly what a row spans, and that span is not symmetric: a row is inset on the left by its own selection
marker (a 3 px bar and a 5 px gap) and runs to the right edge of the list's content. Steam's Field, which every row is
built from, adds nothing horizontally inside the QAM — it renders in its Classic mode there, whose only padding is 10
px top and bottom — so there is no Steam inset to match and a symmetric padding on the header is simply narrower than
the rows. The header and the rows sit inside one element, so they move together whatever inset it sits in — including
the room each pane's region keeps for Steam's focus ring (§ Room for the focus ring), which this layout adds none of its
own to.
Tables¶
Anything with more than two facts per row is a table with a header row: BIOS files (File, On disk, Contents), the preview (a row per platform; New, Updated, Removed), registered devices, cleanup candidates, a kind's collections. Those facts were once folded into a field's label and description, which is why #1803's third axis had no slot on the rows the System page drew; the platform detail's BIOS table is where that column now sits.
There is one table, and a page passes the register it is set in (PaneTableHeader / PaneTableRow in
frontend/src/bigpicture/layout/pane.tsx). The shape is shared — a grid of a page's own columns, an 8 px gutter between
them, the header's names in the secondary size and colour, a row that is a focus stop, a cell that clips — and what a
page varies is the type size, the leading, the row and header padding, and whether a hairline sits under the column
names. That is a TableRegister, and the default is what a pane uses unless it says otherwise. It is one component
rather than three because three drifted: the same header was written three times, and only one of the three clipped its
cells.
A row is a focus stop unless one of its own cells carries a control — the BIOS table's action column is the case, and there the button is already the stop, so a second one on the wrapper would put a dead step in front of every one of them. A cell opts out of the clip for the same kind of reason: a cell of glyphs has nothing to ellipsise, and a cell holding a button must not hide the overflow its focus ring is drawn in.
A cell clips; it never overflows. A grid track sized minmax(0, 1fr) shrinks under its content and the content then
spills across the track beside it — on the Deck a platform name and its note ran into the New column's digit. The clip
(overflow: hidden, text-overflow: ellipsis, white-space: nowrap, min-width: 0) belongs on the cell, because
a grid item is blockified and those properties apply to it, where an inline span nested inside it is not and the same
three do nothing at all. What the clip takes away is handed back in a title, note included.
Destructive actions¶
Last in their group, red, behind the confirmation they carry today — two-tap or modal. Nothing here changes the backup-or-confirm rule in the invariant register.
Text input¶
Two kinds of text input, and they go in different places. A value input opens a modal — Settings' RomM URL, custom headers, account, SteamGridDB API key and default slot: the reader types a value, confirms it and is done, nothing on the page has to be seen while typing, and the on-screen keyboard needs the room a page cannot give. A search that filters a list sits inline, above the list it filters — Library › Collections, and the whitelist on Data Management's Other non-Steam games pane: its point is the result beside the field, narrowing as the reader types, and a modal would hide exactly that. What decides is what the reader has to see while typing, not how much room the keyboard wants.
Notices and homes¶
A notice on Main names a condition and jumps to its home; the action exists only there. A condition with no home in the plugin stays a card without a jump, with Dismiss where the condition has a sensible end.
| Condition | On Main | Home |
|---|---|---|
| Settings were reset | text, backup path, Dismiss | none — the card is the whole of it |
| Cross-device playtime needs a fresh sign-in | text, Open Connections, Dismiss | Settings › Connections, where the accounts are |
| RetroDECK paths missing or unreadable | warning card, no action | none — the fix is outside the plugin |
| Steam answers for no notifications | warning card, no action | none — the fix is outside the plugin |
RetroArch input_driver is wrong |
text, Open Controller | Settings › Controller, which holds the Fix button |
| Sync paused on the session budget | text, Open Sync | Sync, which holds Restart Steam now and Resume |
| A newer Tender release is out | both versions, Open Updates, Dismiss | Settings › Updates, which states both versions and holds the check's switch and Check now |
Every row of that table is what the panel does today. The two full-page states — a version error and a pending RetroDECK migration — are not notices; they replace the page, and neither carries a condition inside it any more: the one that did was the pre-rename plugin folder, which went with the plugin loader.
The notifications row is the one condition read from the start-up check's report rather than from a backend answer or an event. What a toast is raised through is two searches into Steam's bundle and a global Steam installs at module scope (how the panel is built and loaded), so a miss is settled before anything mounts and cannot change afterwards: there is nothing to subscribe to and nothing to poll. It is a notice rather than a refusal to mount because the panel is entirely intact without it: syncs and downloads run, and every result a toast would have announced is on the page it belongs to.
The playtime notice is the one that carries two buttons, and they sit side by side on one row rather than on two full-width ones: Main is the narrow page, and a notice costing three rows pushes the status block it sits above off the screen. Its jump is not an answer either — only a fresh sign-in ends the condition, so Open Connections leaves it standing and Dismiss remains the way to put it away for this view.
The update notice is the other one with two buttons, side by side for the same reason, with no horizontal padding so
each label fits on one line, as the Platforms tab's Enable all pair does. Its Dismiss is per version: it records the
version the card names (update_notice_dismissed_version), so the next release raises the card again, and Check now
in its home forgets it. The home states the versions and holds the check's switch and Check now; it installs nothing,
and to a run from a checkout it shows a line naming this a development build. The card's condition is available on the
backend's answer and nothing else — a newer release with its tarball and a valid digest attached, not the dismissed
version, the check switched on. The answer is fetched at panel load by a detached call nothing awaits (the store's
fetchUpdateNotice says why), and rewritten by Dismiss, the switch and Check now.
Four of the seven conditions above carry no Dismiss anywhere — RetroDECK paths, the missing notifications, the
input_driver fix and the session budget — so the absence is ordinary.
Main¶
Narrow, in this order: the settings-reset and playtime-scope notices, each a titled section of its own, both above
everything else; the status block — the RetroDECK warning and, where Steam answers for no notifications, the warning
that says so, then Connection, Last sync, Library, then the conditional slot and, while a run is going, Cancel Sync,
then the transient line a just-ended run leaves behind (and a cancel whose call failed), and under all of those the
three notices that carry a button (the RetroArch input driver, a run paused on the session budget, a newer release); the
download summary (up to two rows, an overflow count, a completed count, View All); the menu — Sync, Library, Settings,
Data Management. Those last three blocks carry no section title at all — what separates one from the next is a
hairline (BlockSeparator), which costs one pixel of height where a heading would cost a whole row. The layout study it
was chosen from is main-layouts.html.
The menu is the navigation that is always there — complete, and always in the same place. The status rows state and do nothing. The single exception is one conditional slot that exists only while the Sync page has something to report; a notice can carry a door too, and it comes and goes with its condition exactly as the slot does. That is the whole rule, and everything below is what it costs and what it buys.
The panel opens on the menu's Sync entry, which Main declares as its entry stop rather than letting the router's default land on the first row of the status block — the rows below state and do nothing, so opening on one spends the reader's first press. What that costs is Steam's own scrolling, and it is a real cost rather than a theoretical one: Steam scrolls the focused element into view, so on a Main tall enough to scroll — a notice or two, an active download — the panel opens part-way down, with the notices that wanted attention off the top. Main fits today with three status rows and a four-entry menu, so nothing scrolls; it is not defended against in code, because a rule that opened somewhere else depending on what is on screen is exactly what the declaration replaced. The mechanism is under "Building blocks".
The three status rows act on nothing. Connection, Last sync and Library are Fields that carry no activate handler,
and they stay focusable all the same — a region scrolls only by moving focus, so a row nobody can focus is a row
nobody can scroll to, and with the panel now opening below them that is the only way back up to them. Last sync
states the newest completed run's age, and where a newer run did not complete it states that run's outcome and age too
— "cancelled 12m ago", "interrupted 3h ago" — on a second, quieter line. With no completed run ever, that attempt is the
only line, so the row never reads a bare "Never" after thousands of games synced (#1318). Reporting a run and offering
to continue it are different questions: an errored run is reported here and is not resumable, which syncResumeState
decides for the button that offers it, on the Sync page.
The conditional slot sits under the three rows and is the one status row that can be pressed; pressing it opens the Sync page, exactly as the menu's Sync entry does. It is not the only pressable thing in the block — Cancel Sync joins it while a run is going, and the notices that carry a button sit below both — but it is the only one that is part of the status. It exists on two occasions and no others:
| Occasion | It says | Bar |
|---|---|---|
| a run is in flight | Checking for changes or Syncing, and the step counter | yes |
| a preview is pending | Changes ready, and its counts — "13 new · 4 updated" | no |
Coarse means coarse: a short label, a counter and a bar. The stage caption, the fine-detail line, the estimate and the
per-unit table all stay on the Sync page — Main says what is, the page shows what is happening. All three come from
the frame, and the label is stated on it rather than derived from it: a preview run and an apply run narrate the same
work queue through frames of identical shape, so neither the stage nor the presence of a plan is evidence of the kind —
the stage alternates fetch/apply inside one apply run, and a plan's absence conflates "this is a preview" with "nobody
has established the kind yet". So the backend claims the kind with the run slot (LibrarySyncStateBox.try_begin_run)
and every frame of that run carries it as runKind: the live event, both terminal frames, and the get_sync_status
snapshot a remounted QAM re-seeds from — which is what makes a QAM reloaded mid-run right rather than guessing. Where no
kind is stated the slot says neither of the two, wording it "Sync in progress"; the numbers beside it still come from
useSyncRunView, which the Sync page reads too, so one derivation of a run serves both pages.
A pending preview's counts drop their zero parts, and one with none of the three names the work it does hold rather than
showing a row of zeros — there being one line to spend, and that being the case where the counts cannot spend it. Two of
those previews can be named: collection changes where the sync would add, drop or re-populate one, and cover work
only where the delta is cover refreshes. A preview holding both reads as the collection, because that is the one whose
result the reader will see in Steam. The rest keep the plain ready to review — a platform re-stamp, which has
nothing a reader would recognise to name, and a genuinely empty delta, which has nothing at all — and the page behind
the slot is where it is said which. An expired preview counts as none; the backend drops one past its 30-minute TTL
(PREVIEW_MAX_AGE_SECONDS). What ticks for that is a single timer aimed at the deadline, not a per-second interval:
nothing on Main counts a preview down, so the only moment the clock changes anything here is the one the slot disappears
at. Main never discards a preview, and since it can no longer start one either, it holds none of the paths that
answer the preview question — the invariant register's pending-preview entry names all of them, and every one is the
Sync page's.
Cancel Sync sits directly under the slot while a run is in flight, and nowhere else. It is an action on the thing being displayed rather than navigation, so it does not break the rule above; the alternative was two presses to stop a run the panel is already showing. It disarms into "Cancelling…" for the backend's RUNNING → CANCELLING → IDLE drain and is re-armed by the run stopping (#1202, RC-B).
Nothing on Main starts a sync. There is no start button, no resume button, and no preview is computed here — the Sync page's own button does all three, and that is where the progress, the answer and, above all, a refusal are reported. Resuming a cancelled or interrupted run therefore costs two presses, which is accepted rather than an oversight: Last sync says the run stopped, and Resume is a button one press away on the page that also holds the run history the reader is about to want.
What Main still owns is the run's END. useSyncRunView hands that back as two callbacks and only the owning page
passes any, or a second page reading the same run would announce the end a second time: the once-per-run announcement,
with the stats re-read it provokes and the ask for a preview the run may have staged, and the correction that follows
when the run's own terminal frame arrives with better wording. The transient status line those write into stays on Main
and is not gated on the run being idle — a cancel whose CALL failed leaves the run in flight, and its "Failed to cancel
sync" has to reach the reader under the rows it is about. The Sync page passes neither callback and reads the same
numbers; what it keys on the run's end for is its own three reads — the run list, the stats and the session-budget
reading all describe the run that just stopped — taken on a stop that carries a terminal stage rather than through a
second announcement of it. The stage is what separates a run's end from that page retracting its own optimistic frame
after a preview: both stop the store's running, and only one of them ended a run.
Steam memory is not on Main. The reading and the session-budget card are on the Sync page, at the home of the button they are about; what stays on Main is the notice naming a paused run and pointing at it. Main reads the session-budget store not at all — its one remaining poll re-reads the stats while the last run is paused, so the notice goes away again if the run's terminal refetch was ever missed (#39).
Alongside the hook, runUnitsStore.ts holds one run's work queue per unit: a row per unit, seeded from the plan and
bound to the run id the plan carried, advanced to running and then done by that run's frames, and carrying what the
unit's apply created and updated. The binding is what keeps a later run off an earlier run's rows — a preview emits a
frame per unit over the same queue and no plan at all, so the step index alone would walk them a second time. The store
outlives every page, so a page opened mid-run can show the units already worked through rather than only the current
one. Main reads none of it — its slot is the frame and nothing else.
Sync¶
Wide, untabbed, and it owns its regions: two Columns — the left flexible, the right 270 px — each scrolling on its own
inside the frame's measured height. The layout study it was chosen from is
sync-layouts.html.
The left column shows exactly one of three things, and the order they are decided in is the order of authority. A
run in flight owns the page: the progress rows are the true state of the machine at that moment, and a preview held
while one is going is not dropped — the store keeps it and the table comes back when the run ends. Then a pending
preview. Then the line saying nothing is waiting, with the button that changes it. The session-budget card sits above
whichever it is, and only while no run is going: a paused last_attempt survives into the resume that clears it, so the
card would otherwise stand over the very run it is asking for.
A swap that takes the reader's focus with it hands focus to the body that replaces it; a swap that does not leaves
focus alone. Both halves are one rule (useEntryFocusOnBodySwap in utils/entryFocus.ts), and it runs in both
directions: Apply Sync removes the button the reader is standing on, so focus lands on Cancel Sync, and the run ending
removes Cancel Sync, so focus lands on the start button of the idle body — or on Apply Sync where a preview was held
while the run went. Where it lands is the frame's own rule (firstBodyStop, on the same 50 ms delay), applied to the
body rather than to the column, so the session-budget card above it is never what the column lands on. The other half
is the point: a reader who has crossed to Options, Steam memory or the run list chose where they are standing, and a
body changing behind them must not yank them out of it — which is what Force Full Sync does, ending the pending preview
from the controls column. So the question the rule asks is whether the element that was standing in the BODY has gone
with it, held as a note while focus moves through the body; it never asks who holds focus after the swap, because a swap
is not the only thing that can take focus in one commit — Force Full Sync goes dead in the same one that ends the
preview. The mount is not a swap: a page opened mid-run is opened by the frame, on the same stop.
A body can swap twice inside the 50 ms, and the placement follows the last one. Working out a preview does exactly
that: the backend stops the run with its own "Preview ready" frame before the sync_preview callable answers, so the
column goes run → idle → preview in two commits milliseconds apart, and each cancels the placement the one before it
scheduled. So the note the rule reads is spent by the placement it causes rather than by a swap that merely observes it,
and the timer asks which body it is landing in when it fires. Otherwise the first of the two swaps spends the note, the
second cancels its placement and finds nothing to answer, and the reader is left with no focus at all — which is what
the device showed (#1814). A placement that lands on nothing puts the note back: the idle body between those two commits
has one button and it is disabled while the call is open, so that attempt places nothing and the swap it was taken for
is still unanswered.
The button says what the press does. With Skip preview off a press works out a preview and adds nothing to Steam, so the button reads Check for changes — deliberately the words Main's conditional slot shows while that run is going, so the button and the state it produces read as one thing. With it on the press starts the run itself, and only then is the name the resume question's answer: Sync Library, or Resume Sync where an incomplete run left work the next one can skip. The line above the button says which of the two the press is — "Nothing is waiting to be applied. Start a preview to see what would change.", or "… Skip preview is on, so Resume Sync applies changes without showing them first." — and it QUOTES the label rather than spelling a name of its own, exactly as the session-budget card does and for the same reason: neither may name a button that is not on screen. The resume is not lost to a button that stops naming it. The scope line under it still says how much there is — "353 games already synced — a resume continues from there." — and what a press keeps or clears is decided by the resume question itself, never by the name that press happened to carry.
The preview is a table. One row per platform the backend reports a change for (Platform, New, Updated, Removed), one
for the RomM collections built from the added and removed names, one for the Steam collections the sync keeps per
platform wherever platform_collection_diff reports a change, and a total row. The total comes from the summary's own
counts rather than from adding the rows up: the platform rows sum to it by construction, and the two collection rows
count collections rather than games, so each carries what changed on its second line and an em dash in every game
column. That leaves the total free to read 0 0 0 over a preview whose only change is a collection membership, with
Apply Sync live under it, so a line beneath the total states what the columns cannot carry — "plus 1 collection added",
"plus 2 platform collections changed" — built from the same two diffs and shown only where one of them has something to
say. The platform-collections row is drawn on the backend's own has_changes — the field the Apply button's condition
reads too. The empty-preview sentence takes that same condition (previewHasChanges) as an argument rather than
deciding the question a second time, so a leg of it with no wording of its own falls to a generic line and the table can
never read "Everything is up to date." while Apply stands over it. A synced: false row is marked rather than hidden.
Where platform_breakdown is absent the page says so and draws the totals alone; it never reconstructs a split it was
not sent. Under the table: the run's scope and estimated duration, the hint about progress being saved (with the sleep
caveat past ten minutes), and the pause advisory when the backend expects one. The deadline rides the section title
rather than taking a line of its own, because on the Deck the column has about four rows to spend and the table is what
they are for. A preview with nothing in the table — cover-only work, a re-stamp, or a genuinely empty delta — reads as
one sentence instead of a table of zeros, and the first two still have an Apply to press. The import choice (#1364) goes
under the table, later.
Three buttons end a preview, and each ends it on both sides: Apply Sync, Refresh (discard, then work out another — the third path, new here) and Cancel. They are the three the reader chooses between; a fourth ending is the page's own, and it is Force Full Sync, below. Apply is rendered and disabled rather than hidden where there is nothing to apply, or where the preview has expired; past the deadline the table stays and Refresh is what moves. They sit above the table, under the section title, and the reading is "here is what you can do — and here is why". That is also the only place a controller can reach them from: every row of the table is a focus stop and a region scrolls only by moving focus, so a button row under a fifteen-platform table is fifteen stick presses from where the column opens, which is where the frame puts entry focus — on Apply Sync, or on Refresh where Apply is dead. The expired sentence travels with the row, directly above it, and it is the only line that does: it explains the title's amber "expired" and names the button to press instead, so it is a caption on the buttons rather than on the evidence, and under the table it would have put a dead Apply a whole library ahead of its own reason. The three lines listed above as sitting under the table stay there — each describes the run the table is about.
While a run is in flight the column is the run view: the whole run as one bar under the stage caption, with the step
counter and the estimate on the section title beside it, and under all of it every unit of the plan — Unit, Status,
Result. Done rows show what their apply produced, the running row shows its stage with its own bar from
withinUnitFraction, waiting rows show what the plan holds for them (an expected skip is worded as the prediction it
is, never as the run's verdict). Cancel Sync sits directly under the bar, above the list — the bar, the stage and
the button that stops the run are one thing, and the table under them is evidence rather than a control; it is also the
only place sixteen units of plan do not stand between the reader and it, exactly as with the preview's three. The unit
list is a scrolling region of its own, taking what is left of the column under the bar and that button: a plan of
seventeen units is taller than the Deck's column, and without it the running unit walks out of sight below the fold.
Nothing moves focus during a run, so the page scrolls that region itself and puts the running row in the middle of it,
clamped to the list's own ends. Both tables are the pane's own table (§ Tables) set in one flat, small register —
COMPACT_TABLE_REGISTER in layout/pane.tsx, which Library › Collections' table uses too. The numeric-column split and
the pieces that are not tables at all live in sync/paneTable.tsx.
Focus lands on Cancel Sync when this body takes the column, by the swap rule above: what it picks is the first stop holding no stop of its own, and every unit row below is a stop too, so what puts it on the button is the button being first — the same ordering the column is laid out for. On the swap ALONE: a run re-renders per frame and none of those is a moment to move the reader.
A run that has ended stays ended. The frames both pages render come from one module store (utils/syncProgress.ts),
and the frontend writes to it as well as the backend: the apply loop stamps a frame per shortcut. That loop cannot stop
the moment a run does — it tests the cancel flag at the end of an item, and the item it is inside was entered from a
shortcut scan that takes seconds — so at least one frame is always written after the run is over. On the device that
frame was the LAST thing in the store, four seconds after a cancel, and the page stood frozen on a run that had ended,
its Cancel stuck on "Cancelling…", until it was left and reopened (#1814). So the store refuses it: once a stopping
frame carrying a terminal stage has named a run, nothing can put that run back in flight. Two more writers have the same
shape — the cover-refresh loop and the chunk seed — and the cancel flag they consult is not even set for an ending
nobody asked for: a heartbeat timeout, a budget pause, a backend error. What the rule takes as a run's ending is a
terminal stage AND a run id, because neither half alone is one: a stop without a terminal stage is a page retracting the
optimistic frame it wrote itself, and a frame naming no run is one the backend has not stamped yet, so recording that
would make every later optimistic start a resurrection of it.
The bar and the counter come from useSyncRunView, the rows from runUnitsStore. A run with no rows — a preview,
which seeds none, a run whose plan was lost to a plugin reload, or the window between a press that cleared the rows and
its plan arriving — shows the frame's own fine-detail line in their place ("Fetching Game Boy Advance (page 12/62)") —
useSyncRunView's own fineDetailText, which no other surface renders; the page says the per-unit detail is
unavailable only where there is neither a row nor a detail line.
The rows are cleared at the press that starts a run, and kept at exactly one press. A plan is the only other thing
that replaces them and it arrives late — after the work queue is built on the two apply paths, and never at all on the
preview path — so without a clear the previous run's units stand over the new one: its done rows still carrying that
run's apply results, and the unit it died in dressed as running by the frames of this one. A resume is the one start
those rows are still true for, because they are the progress it continues from. Nothing on the wire tells a resume from
a fresh start — it is a new run with a new id, and the backend has no resume concept at all; what carries one is the
per-unit skip gate, a fetch-time decision — so the discriminator is the page's own reading at the press,
syncResumeState(stats).canResume. That is the resume question itself and not the button's name: with Skip preview off
the button says "Check for changes" over a resume the rows are still true for. A press landing before the stats have
answered reads as a fresh start and clears: not knowing is not evidence of a resume, and the cost of being wrong that
way is the fine-detail line for the pre-plan window rather than another run's units.
Every row of both tables is a focus stop — a Focusable with an activate handler — because a region scrolls only by
moving focus, so an unreachable row is an unscrollable one.
The right column, always: Options — Skip preview as the persisted setting, and Force Full Sync, red, last in
its group, behind a ConfirmModal stating that it forgets what was synced and rebuilds everything. A successful clear
ends the pending preview on both sides, the way Cancel does: the clear has just discarded the state that preview was
worked out against, so applying it afterwards would skip exactly what the clear armed a re-fetch for. It is the fourth
path that ends a preview, and the only one the page takes rather than the reader.
Force Full Sync is rendered and disabled, never hidden, and the line under it says which state it is in rather than
describing a press: while a run is in flight; while a clear already made is waiting for its run, since pressing again
would clear nothing (that state ends when a run has been and gone); while the stats say nothing has been synced; and
while the stats have not answered yet. Those last two are both a null snapshot and they part here. A read still on its
way disables the button, since not knowing is not evidence that there IS something to clear. A read that failed
leaves it live and says the reading is missing, because a failure is not an absence. The page's one unconditional stats
read is the one on mount — every other is conditional on something happening (a run ending, a clear, a poll while the
last run is paused) — so a mount read that never answered used to leave the button dead for as long as the page stayed
open, which is what a cancelled preview left behind on the device (#1814). Skip preview stays live throughout: the
setting is read by the next press of a start button, so flipping it during a run changes nothing about that run. Steam
memory — the reading now and the last run's delta, saying "unavailable" and "not recorded" rather than showing a zero.
Last runs — the ten newest, newest first, each a focus stop with the start time, what the run covered and how it
ended.
Working out a preview is this page's call, and only this page's. Nothing computes one on open — not this page's own mount and not Main, which starts no run and computes nothing; its one sync control is the Cancel that ends a run already going — so a preview happens because the reader pressed the button that asks for one, right here. Everything the call can answer — the run while it works, the table when it lands, and a refusal — is therefore reported where the reader is looking.
What the backend holds for it: the preview answer carries library-wide totals (SyncPreviewSummary: new, changed,
unchanged and removed counts, the platform and collection counts, and more), the names of new and changed games, and the
added and removed collection names (collection_diff). The same counts split per platform ride the summary as
platform_breakdown — one row per platform holding at least one non-zero count, ordered by display name, each carrying
synced for whether the platform is in the run's platform list. A synced: false row is a platform outside it: its
toggle went off, RomM stopped listing it, or the only route to it is an enabled collection, which is not filtered by
platform enablement. The causes compose, so one row can carry removals for the ROMs the run no longer fetches and new or
changed counts for the ROMs a collection still reaches. Its name is the run's where there is one, else a real name
carried on one of the platform's fetched entries — a reconstructed collection member carries the slug there and does not
count — else what the backend recorded, and the bare slug where no tier answers. There is no collections row there:
collection_diff on the same summary already carries the added and removed collection names. The Steam collections kept
one per platform are a third field, platform_collection_diff — has_changes and an added and a removed count, no
names, which is why the page's row for them states counts where the collections row states names. get_sync_runs
answers the ten newest sync_runs rows of any status, newest first, each verbatim from the SyncRun aggregate (id,
started, finished, status, planned counts, completed platforms and collections, error) — a field a run never recorded
stays null, and the status is what says why. Skip preview is a user-intent setting in settings.json written by its
owner (adapters/persistence.py) and reported by get_settings. No backend sync path consults it: the choice between
asking for a preview and starting the run is made on the frontend, by this page's own start button.
Library¶
Wide, two tabs.
Every sync write on either tab is optimistic, and a write that does not take says so. A switch shows its new value
before the backend answers. A refusal or a rejection is one outcome — none of the write callables throws to refuse. The
switch goes back and a line says why, both within the rule below; the line carries the backend's own message, or a short
fixed sentence where there is none (SYNC_WRITE_FAILED), and takes no space otherwise. Without the line a switch that
goes back is indistinguishable from one that never moved. A later write in the same place that succeeds takes the line
back, within the rule below. Which writes each tab makes, and where its lines sit, is under the tab below.
Only the latest write speaks — on a switch's value, and on a line. A switch — a platform, a collection, the
owner toggle — keeps the value last stored for it, taken from the read and moved on by every write that succeeds, and
numbers the writes issued to it. Enable all / Disable all number every switch they write. An answer changes what the
switch shows only while its write is still the latest for that switch: a success leaves the value the write showed, and
a failure puts back the stored value. An answer that is no longer the latest only moves the stored value on, when it is
a success, and never touches what is shown. So two refused writes to one switch leave it showing what is stored, a
refusal of an older write cannot undo a newer one still in flight, and a refused Enable all puts back only the switches
no later write has touched. A line numbers the writes issued in its place, and an answer sets or clears that line
only while it is still the latest write there and the tab has not been entered again since it was issued; entering a tab
also clears its lines. So a slow refusal cannot stand a line over a write that has since succeeded, a slow success
cannot take back a newer refusal's line, and nothing lands on a view entered afresh. The bookkeeping is
frontend/src/bigpicture/library/latestWrites.ts, shared by both tabs' hooks.
Platforms is list and detail. The list holds every platform RomM reports with at least one ROM — what
get_platforms returns; a platform with nothing to sync is not listed — in two groups, Synced (the toggle is on)
above Available, each alphabetical: a dot, the name, the toggle, and nothing else. The dot is the row's whole
BIOS signal, through the shared mapping every platform-level BIOS dot renders through
(frontend/src/utils/biosColor.ts: green complete, amber partial, red missing, grey for a missing level; the per-file
rows on the platform detail and the game page hard-code the same four colours). It is drawn on every row, taking exactly
the helper's grey where there is no level to state: one that came and went shifted every name beside it, and the list is
meant to be scanned down its left edge — which matters more now that the dot carries the state alone rather than
reinforcing a number beside it. What the dot means in words is the row's title, and it is literally the pane's
sentence: both come from biosSummary (see the BIOS files bullet below), so the number the row used to print arrives
inside that sentence wherever the state has one.
The row carried the ratio (3 / 5, an em dash where nothing is required) until the second device round, and that is
superseded rather than forgotten. The first device round asked for it and it was added; using it decided the opposite
— a number in a line you scan past earns nothing when the pane one keypress away states it properly, with the files it
is made of. The layout study still draws it; on this point the study is superseded, and so is the earlier round's
finding. Do not restore it as a regression. Enable all and Disable all sit above the groups, in the list column and
outside every row, so reaching them reports no selection. The order freezes while the page is open.
The sync writes are a row's toggle and Enable all / Disable all, which write every row in the list, and they follow the rule at the top of § Library (Only the latest write speaks). Both report in one line, under those two buttons: in the list column, scrolling with the rows, plain text rather than a focus stop. Either write is refused while a RetroDECK path migration is pending, and Enable all / Disable all also when the RomM listing they need fails.
The detail offers no sync control of its own — the row already is one, focus is already there and A works the toggle,
and the list's two header buttons act on every row at once — so it opens with one header line instead of a Sync section:
the platform's name, N on RomM · M in Steam · <core name>, and the core picker's icon button, right-aligned.
Both counts on that line are ROM files. N is RomM's own rom_count for the platform; M is reachable_count —
the platform's reachable ROMs (every member of a sibling group that holds a binding, because one shortcut serves the
group (ADR-0021 §2) and the game's page switches versions across it), less the versions RomM no longer serves (below).
M is not the number of shortcuts: a fully-synced 665-ROM platform behind 458 shortcuts reads 665 · 665, where
counting bindings read 665 · 458 and so reported 207 games as missing when none was. The number of shortcuts is
count on the same payload, and it is what the Remove group says and acts on — the two must not be folded, or the
button offers to remove more shortcuts than exist. Where a whole game never reached Steam the two halves genuinely
differ (3084 on RomM · 8 in Steam for a platform with one applied game), and that difference is the line doing its
job.
Two things the line does not claim. The halves count different populations — the left is what RomM holds now, the
right is what our own rows say — so ROMs added on RomM since the last sync widen the gap, and equality means "nothing
outstanding as of the last sync" rather than a fresh server-side proof. And a version RomM no longer serves is
reachable but not counted: nothing deletes such a row — ADR-0007 keeps it as an identity anchor and only the
removed-game cleanup removes one — and its group's shortcut still reaches it (CONTEXT.md → Reachable), but the right
half does not count a version RomM has stopped serving as in Steam. reachable_count is the reachable rows less those
the fetch its completion stamp records did not return, which domain/fetch_generation.py::prune_candidate_ids already
answers for the cleanup's own discovery: every row not carrying the fetch generation the platform's completion stamp
recorded, a row carrying none included. Where no usable stamp exists — none, one with no generation, or one recording an
empty fetch — it names nothing and every row counts, so the exclusion's worst case is the number printed before it.
That leaves one window in which the line can read right > left. A ROM deleted on RomM drops out of the left number
at once, while its row, where its group holds a binding, still carries the generation the stamp recorded and so still
counts on the right. Short of removing the platform's shortcuts, which takes the platform out of the payload, only a
sync that applies that platform closes the window: the stamp and the rows' generation are recorded only by an
apply's commit (Backend Architecture,
"Incremental skip", and domain/fetch_generation.py), and a preview writes neither. So the window survives a sync that
ends at "Everything is up to date.", and such a sync is possible: an unbound version deleted from a group that still
holds a binding changes no shortcut, and the preview's removals count bound rows only. An apply that stops inside the
platform, however it stops, leaves it open too, and wider: it deletes the stamp at its start, and with no stamp every
reachable version RomM no longer serves counts again. Closing it without a sync would need a live server call, which
this read deliberately does not make — get_registry_platforms answers offline, and that is what keeps the pane useful
with RomM unreachable.
The exclusion also means reachable_count is not bounded below by count: a bound row the stamp's fetch did not
return raises the shortcut count without raising the header, so a pane can read 2 on RomM · 3 in Steam beside
Remove 4 shortcuts. Two shapes reach it — a bound version deleted on RomM, in the gap before that run's stale-removal
scan, and a collection-added row on an already-stamped platform, which commits with no generation, so the exclusion
leaves out a row RomM still serves — and both heal at a sync that applies that platform: the second at the platform's
commit, the first at the stale-removal scan of the run that opened it. A stopped run skips that scan, and later runs
skip the unchanged platform, so after a stop the first waits until the platform is fetched and applied again (for
instance when RomM changes it, or after Force Full Sync). The direction is a conservative under-count, which is why it
is recorded rather than guarded.
The BIOS ratio is not on that line — it was, and its width is what wrapped the line three times on a platform with a
long name and a long core label. It is stated once instead, beside BIOS FILES eight pixels below, in the colour
biosColor.ts gives the list's dot, so the two places that state a platform's BIOS state agree by construction. Under
it, for the focused platform:
-
Emulator core — a microchip icon button in the header line, opening the same context menu the game page uses (
buildEmulatorMenu). It is the game page's own button and its own colour coding: grey#8f98a0when the active core is the default option, gold#d4a72cwhen it is an override, read off the payload'sis_defaultfor the option carryingactive_core_label. The core clause beside it takes the same two colours from the same condition, so the name and the icon cannot disagree. A full-width button under the header, with the save-compatibility caveat under that, is what this replaced: two rows for one action, on the pane where rows are the scarce thing. The caveat is not lost —buildEmulatorMenurenders it as the menu's first item, so the copy on the page that opens the menu was the same sentence twice.The clause names the core; "Default" is not one of the names it can take.
resolve_platform_labelanswers with the real label in both ordinary cases.nullmeans no option is bakeable, which is not the same as there being none, and the two are different sentences:- Options exist, none bakeable, and the fallback can run — the plain RetroDECK launch is baked and RetroDECK
resolves the emulator itself. The clause reads
RetroDECK decidesin the muted colour and the line under it says the plugin cannot pin one; neither promises a launch, because what the fallback then finds is between RetroDECK and the machine. - Options exist, none bakeable, and the fallback is not installed — the same unpinnable state, with the opposite
outcome.
run_game.shtakescommand[1]for the system when no alternate emulator is set andoptions_to_payloadkeeps ES-DE's document order, soemulators[0]is that command; when its ownreasonisnot_installed, the fallback names a binary that is not there. The clause readsno emulator installedin red and the line names the emulator RetroDECK would have used. Onlydowngrade_if_not_installedever sets that reason, and only on an otherwise-bakeable option, so the branch fires exactly where the first command's standalone emulator is missing. A first command unbakeable for another reason (quoting) whose emulator is also missing keeps the muted sentence —macintoshis that shape — because installedness is not established for an option that was never bakeable. Apple I is the muted case, not this one: ES-DE gives it two live commands, both MAME, and the first is a libretro one whose core is installed, so it readsRetroDECK decidesand its games start. The three standalone entries in that block are commented out and are not commands at all. - No options at all —
_resolve_systemfalls through to the raw RomM slug for a platform its map does not name, andget_emulator_optionsanswersavailable: truewith an empty list for a systemes_systems.xmldoes not list;vic-20,acorn-electron,nintendo-dsi,ps5,browserandwinare in neither. RetroDECK's own launch then readscommand[1]for the system, finds nothing, and exits 1 (libexec/run_game.sh). The clause readsno emulatorin red and the line says the games will not launch, because they will not.
The chip is disabled for all three, never withheld. Printing "Default" for any of them said the plugin had chosen; printing
no emulatorfor all three said the games would not start where they do. Both were wrong, in opposite directions, and the middle state is why the split is three rather than two: it is unpinnable like the first and does not start like the last.The button is always rendered, and opens a menu only when there is something to pick: the core read landed, RetroDECK was found, at least one option is bakeable, and there are at least two. The platform's shortcut count is deliberately not a condition: the per-platform core is a setting read when a game is resolved, so it applies to games synced later too, and picking it before the first sync is the ordinary case. In every other case it is the same chip, disabled, with the reason in its
title— the ruling the Remove group already follows, and what keeps the header's shape constant across panes. A disabled button is still a focus stop and the wide page's own sheet gives it Steam's focus outline, so a reader walking the header lands on it and is told why.Which of those cases also keeps a line under the header is a judgement about what it reports, not about the chip. "Nothing to switch" states — the read in flight, one emulator on the menu — say it in the tooltip alone: a sentence would spend a row of the pane reporting that nothing can be done, which is what the device round asked to remove. States that report a PROBLEM keep their line, because a tooltip is a hover and the Deck's controller cannot perform one: the read failed, RetroDECK was not found, ES-DE lists no emulator at all, nothing on its menu is bakeable, and the fallback is not installed. The first two of those three are the split above and they are checked in that order: an empty menu is the case where RetroDECK's own fallback fails too, so it is answered before the not-bakeable one, and the surviving count branch then speaks only for a menu that really does hold one bakeable option. A switch the backend refuses is reported in the same place, and the header keeps naming the old core, which every shortcut following the platform's pick still launches with. A switch takes the page's busy hold from the moment it is picked until it is over; an accepted one re-bakes the launch command of every bound shortcut, which is why the hold has to cover the whole of it. The chip and the pane's buttons disable, another platform's pane says
Working on X, and the acting pane saysSwitching 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. - Options exist, none bakeable, and the fallback can run — the plain RetroDECK launch is baked and RetroDECK
resolves the emulator itself. The clause reads
-
BIOS files — the summary, which this pane words nowhere:
frontend/src/utils/biosSummary.tsholds all seven states and answers each in two lengths, and the pane takes both — the shortstatusas the section's coloured note besideBIOS FILES, thesentenceunder it, with the library's own(d/t RomM library files)ratio behind the sentence in every one of the seven. The ratio was a description line of its own here, and only in the state that said nothing was required; the shared sentence replaced that line and took the ratio with it, while the game page went on appending it to every sentence — one platform, two surfaces, two different amounts said about it. (system_image: "absent"outranks the counts and the decline alike, tested before either inside that module, because the console asks for one of the images and no count can state that;"unsettled"andrequired_withheldare declined VERDICTS over rows that answered, so neither reachesnothingEstablished— which is now the narrowest decline and decides one extra LINE only, the by-hand route.) Then a table: File, On disk, Contents, and a Download button on every row that is missing and in the RomM library (#164) — never on a folder declaration, whatever its state, because the emulator opens that name as a directory — and a Delete button on every row a download record of ours still holds. That covers a declared folder too, where no record carries the row's name and the button counts the distinct files our records name underneath it (Delete (N)): a folder is never a download, which says nothing about the files already inside one. Same authority asDelete BIOS, described below. Below the table one row of buttons: Download required (N), Download all, Delete BIOS behind aConfirmModal. All three are always rendered and disable when there is nothing to do, the ruling the Remove group already had: on PS2 all three vanished at once, and a button that disappears is a state the reader has to work out. A disabledDialogButtonis still a focus stop, so the row stays walkable.Every sentence names the emulator, off the firmware payload's own
active_core_label— the label half of the pick those very counts were filtered by, never the core read beside it on the page. An emptyrequired_countis worded "<emulator>marks none of its BIOS files as required" andabsentis "<emulator>cannot start this system without a BIOS image", because both are statements about one emulator's declaration while the console's own demand is the separate axis beside them —not_demandedfor a console nothing is recorded about as readily as for one shown to start with nothing — so a subjectless sentence stated the count's conclusion as though the console had been asked, and the olderabsentwording additionally said no BIOS file was in the folder where what was read is that none of the DECLARED images was. Where the pick has no label the sentences name the role instead ("The launching emulator …"), and the shortstatusbesideBIOS FILESstays subjectless because it is a heading, with the sentence under it where a name fits. "Emulator" and never "core": what a platform launches with can be a STANDALONE emulator, which is not a core, and the whole answer is keyed on the emulator's identity for that reason.The game page's BIOS tab reads the same module and shows the
sentencealone, with the same ratio appended in the same words — a third set again, which is why it rides along on both rather than being folded in. The ratio names that set in its own words, because the sentence in front of it counts another one and the numbers cannot say which is which:The one file DuckStation requires is in place (1/20 RomM library files)states three correct numbers over three sets, and the words are the only thing that tells them apart. The pair is the library's inventory for the platform — what it holds, and how many of those the plugin found at their destination (CONTEXT.md → Library inventory) — and the tail names no axis of its own deliberately: the ratio form carries that, and each candidate word for the numerator was worse than none. Two of them are already on the screen under this sentence and stand for something else there —presentis the row marks andon diskthe 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 thanlocal_countcounts:on_serverrows whose file is at its destination, the field itself answering presence and nothing more. Neither surface prints the ratio where the library holds nothing for the platform:(0/0 RomM library files)counts a set that does not exist. What stops a surface writing one of these sentences back into itself isfrontend/src/utils/biosSummary.test.ts, which reads the components as SOURCE and fails on any phrase the module builds its answers from, withbiosHeldRatio.test.tsdoing the same over the ratio. Both SWEEP the set they search rather than naming it — every non-test.tsxunderfrontend/src/bigpicture, viafrontend/src/test-utils/componentSources.ts— because naming it is what failed: the lists held two while three surfaces rendered these states, and a surface left off a list cannot be told from one that never drifted. Deriving the set from who imports the module would be worse still, since a surface wording a state for itself is precisely one that does not import it. What the sweep cannot see is a NEW wording invented for one of these seven states; no string search can, so a green run is evidence about copied sentences alone.The Platforms list's row tooltip reads the same module too (
PlatformsTab.tsx'sbiosTooltip) and takes thesentence, so hovering a row and opening its pane give one wording rather than two. It was the last one in, and while it was outside the module it was also outside both locks — so it went on saying an emulator "requires none of the files it names" after the other two had moved to naming the declaration, and one platform was described two ways a keypress apart with a green suite. It words three answers itself, and none of them is a BIOS state: a row whose read has not arrived, one whose re-read failed, and one with no payload at all. Those are about the READ, which the module has no input for, and each is taken offfirmwareStaterather than off the payload being absent —firmwareis non-null in theansweredstate alone, so all three of these share it and a truthiness test cannot tell them apart. Of the module's two lengths it takes thesentence, because a tooltip carries no heading beside it and the shortstatusneeds 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,
isFetchableinfrontend/src/utils/biosFetchable.ts, overon_server && !downloaded && declared_kind !== "directory", and neitherbios_levelnorrequired_withheldnorsystem_imageis read anywhere among them. The predicate lives outside this file because the game page's BIOS tab asks it too — there it is one of the answers that earn a row a line at all, since a file no page can fetch, that the launch does not require and that is not there, is nothing that page can act on. What each surface DOES with the answer stays its own: the game page's rule for keeping a row is four further answers wide and belongs to that page, and this one has no such rule. Two further inputs sit beside that filter and are of the same two kinds rather than readiness gates:Download requiredcountsrequired_by_active, the launching emulator's own declaration, andDownload allstops at the library's own finished ratio. Readiness is not an input to any of them, in any of its shapes: what the resolver could establish is the EMULATOR's demand and what is fetchable is what the RomM library holds, and neither answers the other. Gating on the verdict is what took the buttons off PS2, GameCube and PSP when a BIOS answer was first scoped to the emulator that launches — those three launch standalone emulators the resolver holds no card for, so the verdict declines over a library that still holds their files, and the pane then offered nothing to press on exactly the platforms that need one.A running download is said by the button that started it. The pressed button — bulk or per-row — becomes a spinner, every other download button on the pane disables, and when it finishes the rows re-read. There is no "Downloaded X" notice any more: a success says itself. A failure still gets words, in the same status line under the section, carrying the backend's own message; for a DOWNLOAD that line is failure-only, and the pressed button itself says
Failedin red for two seconds before everything returns. The platform-wide Delete BIOS still writes its result there on success too ("Deleted 3 BIOS file(s)"), which is the one outcome on this pane no row can show; a row's own Delete says it by the row changing. The spinner is keyed on the run's slug as well as the button's identity, so walking to another platform mid-download shows disabled buttons and the "Working on X" line rather than a spinner that belongs elsewhere.The per-row Delete is authorised by the download record and nothing else — the row carries
deletable_count, which the backend derives from the same records the platform count comes from (_stamp_deletable), and the unlink re-reads the record and takes the path it holds.downloadedisos.path.existsand is equally true of firmware RetroDECK ships:dolphin-emu/Sys/codehandler.binsits one row above a real download on a GameCube pane, no RomM library can hand it back, and authorising on presence destroyed exactly that file on a real device. All three buttons — the platform's, a file row's and a folder row's — run one removal loop (_delete_recorded_io) under different record predicates, because a second copy of that loop is exactly what the register's BIOS-delete rule warns about.On diskholds 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 offBiosFileEntry.satisfiedand never off presence, because for a folder declaration the two come apart entirely. The colour is the need: strong where the launching core requires the file (green✓, red✗), muted where it does not (pale green✓, grey✗), keyed onrequired_by_activeso the table and the summary above it cannot mean different things by "required". Two states have no place in that four-way scheme and are not folded into it, and they are not the same state either. A verdict nothing could establish is an amber?— the glyph channel has nothing to say. Awanted: "unknown"row — no placement in the platform's catalogue, and the launching emulator's reading incomplete (reading_complete_for) — keeps its verdict, which IS established, and goes amber on the colour channel alone: an amber✓or✗. Reading the need axis first would spend the glyph on a need-axis fact and throw the verdict away, on exactly the platform made entirely of such rows.optionalandnot_neededdo share the muted branch: for the core about to launch, neither is a gap.A fifth state replaces the muted answer where the row is one of several images any one of which starts the console (
BiosFileEntry.system_image_candidate). Such a row is neverrequired_by_active— its core marks every one of them optional, which is all a libretro.infocan say about a disjunction — so the four-way scheme drew five grey "missing, not required" marks under a red headline saying the console needs one, and a reader took the grey marks at their word. What is true of the row comes from the PLATFORM'ssystem_imagerather than from the row:absentmakes each of them a way to fix it (red✗),heldmakes the rest genuinely spare (grey✗), and anything else passes the doubt on (amber✗). A candidate whose verdict is met is the console's held image and is drawn green — the candidates are a subset of the rowsclassify_system_imageweighs, so it cannot be anything else. The two amber states above are tested FIRST and are not displaced: an unestablished verdict is still?, and an unestablished need is still amber.Mark 2,
⊘in violet, appears beside mark 1 whereveron_serverisfalseand 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 rowon_server: Falseunconditionally and the mark would say "your library does not hold this" about something nothing could. That is the sentencebiosFileNotealready 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 inCLAUDE.mdowns that rule.A legend under the table names the marks it actually contains, one entry per line — an entry for a state no row is in explains nothing and costs a row, and mark 2 is inside that filter with one line of its own rather than one per verdict it can stand beside. An entry's identity is its sentence, which is also the row's own
title: since the console's own demand became a state, glyph plus colour names two different sentences at once (red✗, green✓, grey✗and amber✗each mean two things), so a legend filtered or keyed on the pair would show one of each and hand the other React's duplicate key. The legend is the only one of the three wordings a controller user can reach (the others aretitleattributes), so it words the amber rows as what they are — nothing could say whether the file is wanted — and never as "nothing asked for it", which is thenot_neededclaim and a synonym of the grey "missing, not required" two lines below it.On a platform nothing could answer for, the rows nothing could be asked about are counted once, in the line under the table that also says where to report the gap; the summary above it states the condition without a number, because the count up there was the same sentence twice on one screen. Counts on this pane are pluralised (
1 file,2 files), never written asfile(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 asfromLibraryso this surface can drop it without re-deriving the helper's precedence, and the game page's BIOS tab, which has room, still prints it. Notes are rare on a healthy install — over the.infocorpus the rows that carry one are the handful RetroDECK supplies itself and PS2's folder declaration. That is not a bound on the vocabulary:biosFileNote's caveat wording ("its location could not be read", "a folder is here, where the emulator opens a file") appears wherever a destination cannot be read, which no corpus predicts.A declared FILE's note also carries what became of its bytes — the row's
checked, the resolver's own word, carried from the same entrydeclarationis. Three of its eight values are three different things behind one withheld verdict and the surfaces worded all of them "could not be checked":unrecognisedis 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;unreadis bytes that did not come back, which the sentence fitted; andrefusedarrives with the verdict alreadyfalse— 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, andrefusedneeds 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_filesbuilds no description at all and_wanted_fieldsoverwrites what came in, so what arrives is the core's ownfirmwareN_desc, or the file name itself for a row no placement covers.Only a
readdeclaration's prose is shown at all, whichbiosFileDescriptiondecides first and both surfaces therefore inherit. The row carries the resolver's own word for how the emulator that supplied the description stated what it wants (declaration, alongsidedeclared_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)onscph5500.bin, a region the name never states — while apackagedcard's is atlas explaining the requirement in whole sentences — "a PlayStation BIOS image — the console runs it before any disc, and DuckStation starts nothing without one — found by the search, not named by any setting". That is an essay on a line sized for a label: it broke off mid-sentence in the platform detail's clipped line and filled the row on the game page. The register is read off the declaration and never inferred from the row, because an identity ending in_libretro.sois 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 whosedeclarationthe 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
.infofiles 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 isbiosFileDescriptioninfrontend/src/utils/biosFileNote.tsand both surfaces apply it, because a rule applied on one is a row reading two ways: the game page's BIOS tab used to head its rows with the raw description, which put the packager's prose where the file's identity belongs — and heading such a row with the declared file instead prints the name a second time under it on every shape that opens with the name, unless this rule takes it back out. There the row's head is the declared path whole rather than a prefix and a name.Where the result GOES is each surface's own, and the two differ because their name lines do. On the game page's BIOS tab the label follows the name on the SAME line, in the packager's own punctuation —
scph5500.bin (PS1 JP BIOS)— which is the form it was written in, with our declared path in place of the bare basename. Three parts read left to right there: the name, then what the file IS, thenbiosFileNote's note behind its em dash, which is how it STANDS. The two marks do the separating themselves — parentheses for an identity, a dash for a state — so the pair does not read as a chain of equals. It is a span of its own inside the name span, muted like the per-core lines rather than coloured like the name (a sibling flex item would take the row's 8px gap where the packager wrote a space), and the block under the row is then what the read found and who wants the file, with nothing in it that is about the file's identity. Under the row is where the label used to be, and against a list of five emulator lines it read as a sixth entry.On the platform detail it stays a muted line under the row, clipped to one line (
nowrap+ ellipsis). That is a column width and not a difference of opinion: that name sits in a ~202px table cell that clips, and it has already given up its folder prefix to the same shortage, so a label hung off it would take the NAME off the screen — the one thing a reader placing a file by hand needs. The reason is stated at both call sites, because "unify the two surfaces" is the obvious-looking change that breaks it. The declared folder goes the other way on both, onto the name line as a muted prefix (dc/dc_boot.bin), where it belongs to the file's identity —declared_pathcarries it, becausefile_nameis a basename andlocal_pathis joined under a root the frontend does not know. 207 of the 695 declarations name a subdirectory and their descriptions spell it in only 115, so the description was never a substitute. On the platform detail a row can therefore carry two lines under it — the description first, thenbiosFileNote's note — and neither is in a cell any more. Contents is answered for a folder declaration only: the count of images it holds (the resolver's verbatim strings are listed full-width under the row,pre-wrap, because the padding in them is what makes a line matchable against the emulator's own picker), or that it holds none, or that nothing could establish its contents. A file row reads an em dash, and that em dash means the question was never asked — the machine-wide reading is deliberately unverified, #1803 is what will ask it, and until then the dash must not come to mean "asked, and nothing found". The section appears whenever the firmware read speaks for the platform, synced or not — there is nothing to say about one it does not cover. -
Remove — Remove N shortcuts and Delete N save files on one row, the actions the Data Management platform modal used to offer, without Delete BIOS (it is one group up). Red, last, each behind a confirmation, and with no heading over them: both buttons name what they remove and are drawn in red, so a title says nothing they do not. Both buttons are always rendered and disable when there is nothing to delete; neither is ever hidden. Hiding the group on the shortcut count alone strands a platform whose shortcuts were removed and whose saves remain — those saves are then unreachable, and this is the only page that offers them. Only the shortcut removal is gated on a running sync (the
remove_platform_shortcutsuse case names the sync rule;delete_platform_savesdeliberately 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 a0would state an emptiness nobody established. A read that failed is the third case and says so in a line under the pair, because with a spinner above it a silent failure is a spinner that never stops; the button stays pressable there, since a failed count is not evidence that there is nothing to delete.
Six reads feed the tab. Three are list-shaped and run once per page mount: get_platforms (RomM's platforms with ROMs,
the list itself), get_firmware_status (which platforms have a BIOS answer to give) and get_registry_platforms
(ROMs bound to a Steam shortcut per platform — the shortcut counts, and what "has synced games" means here). Only the
first gates the list; the other two fill in beside it, and each says so on the pane when it fails, because for both
of them a failure and an answer arrive the same way — as an absence. A failed get_registry_platforms read as zero
shortcuts would print 0 in Steam in the header and disable the removal, two claims about a platform nothing was
learned about; the counts go to null instead, which is not zero, and a line under the header says the number is
missing while the removal stays live (it needs only the slug).
The fourth is get_platform_firmware_status, and it is asked once per platform rather than once per page, because
what a platform's BIOS state IS costs a live per-system reading of the machine. Measured on the reference device: the
reading itself is 67-350 ms per system and a platform's whole answer 106-486 ms, so a 28-platform library put 4.1 s in
front of the first row when one call answered for all of them — where the call that merely names those 28 takes 4.6 ms.
The page therefore renders from the cheap reads and the answers arrive underneath it, and four rules hold:
- The frontend owns the order. It walks its own list top-down and puts whichever row the reader is on next, because that is the pane that is open. The backend answers one platform at a time and builds no ordering of its own.
- A row waiting for its answer is drawn and worded apart from one nothing could be established for. Both hold no answer, so both would otherwise be the grey dot that means "unknown" — an ANSWER, and the wrong one, over most of the list for the first seconds of every visit. A waiting row draws the dot as an outline and says "Checking…"; the solid grey dot keeps its meaning.
- Leaving the page stops the work. No further platform is asked for, and an answer already in flight is dropped rather than written — a visit's answers belong to that visit, and a fresh one has re-read them.
- A BIOS download or delete, and a core change, re-read that platform and no other. The files (or the emulator) changed for one platform; re-reading the library would pay a live reading per platform for it.
Two reads for one platform are ordered by a counter taken when each is issued, and only the newest may write. The core picker is offered while a platform's BIOS answer is still coming — it needs no BIOS data — so a core change really can put a second read in flight for one platform, and the older one lands describing the core that is no longer picked. Its only symptom would be a wrong colour.
A read that failed is worded apart from a platform the overview genuinely has nothing to say about, which is a finished answer. So is a failed re-read: an answer already held is not taken back, the pane goes on showing it, and a line above says it may be out of date — and only that platform's pane says so, because only that platform's read failed. Picking the platform again retries it, which is what the line says; only that and the save count recover without reopening the page.
The last two are per-platform-slug reads issued once per selection and cached for the life of the page.
get_system_core_info exists because neither the list reads nor the BIOS read can answer for the focused platform at
the moment it is focused: get_platform_core_info is keyed by ROM and layers that ROM's own pin on top, and the
per-platform BIOS answer carries the same emulator fields but arrives whenever the walk reaches that platform — which is
not when the reader opens its pane, and never at all for a platform the page has nothing to say about. It costs one
ES-DE options read and a settings.json lookup, and opens no database transaction. count_platform_saves answers how
many save files the platform holds, for the Delete N save files button: nothing else knows the number, because the
delete finds its files through the platform's installed ROMs and counts only what it removed, afterwards. It walks that
same path without deleting — and like the delete it first follows each ROM's moved save directory, so it can move files,
back up a collision and write a ROM's record before it counts. That path is 6N+1 short BEGIN IMMEDIATE
transactions for N installed ROMs, not one: 7N+1 where no ROM's directory is recorded yet, 4N+1 with save sync off,
when nothing is followed. Counting rule: SqliteUnitOfWork.__enter__ calls during one count_platform_saves call, over
the contract harness's real wiring — the real ActiveCoreResolver, no per-game pin — with only the save resolver's
reading of the machine faked. The platform's id read opens one (SaveService._installed_rom_ids_on_platform); then, per
ROM and under its lock, the live reading opens three — RomInfo.is_content_installed's install row, the save answer's
own install row, and ActiveCoreResolver.active_emulator_for_rom's read of the ROM and its install, after which it
resolves through get_emulator_options(system) (the heavy read, which globs each option's emulator install through the
find rules) and the resolver reads the machine for the answer itself; the follow opens two — the pending-home-migration
check and the record's read, plus the record's write at first sight; and find_save_files opens one more for the
install row. On a 128-ROM platform that is 769 lock acquisitions. That cost is deliberate and is not the read's to fix:
it must walk exactly what the delete walks, or the number offered stops being the number taken. What keeps it out of the
way is that it is offloaded off the event loop and asked once per selection, and that a failure — SQLITE_BUSY among
them — degrades to a line saying the count could not be read rather than to a wrong number — and that failure forgets
the slug, so re-selecting the platform asks again, which is what the line says and is the only failure on this pane that
does not need the page reopened. It is asked again after a delete, so the button stops offering saves that are gone.
Collections is list and detail too, and the list holds the kinds, not the collections: kind is where a reader already navigates, so it becomes the list, and a collection is a row in its kind's pane. The layout study the shape was chosen from is collections-layouts.html. From the top, the list column carries:
- Collections — the standard kind, the Favorites row's collection left out — and Smart collections.
- Autogenerated, a group heading over two rows, Franchises and IGDB collections.
- a thin rule;
- Favorites, a row with its sync switch in it, as a platform row has. Its count is the favorites collection's ROM count.
- Other users' collections, a row with its switch in it, as Favorites has, and the list column's refusal line under
it. Its count is how many of the collections RomM lists are other users', the number alone — its pane says whether
they are shown — with a dash where the read failed, and nothing while the read is out or while Tender cannot yet tell
whose a collection is. It is the owner scope (CONTEXT.md → Collection owner-scope): on is
all, off isown. 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:
- the kind's name with a short description;
- 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;
- 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;
- 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 atSYNC_CELL_TOGGLEinCollectionsDetail.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 whereis_ownistrue, and the owner's RomM user name otherwise, a dash where the listing carried none. In Steam reads the count, and a dash where it is absent, which is unknown rather than zero. Collections that are on sit above those that are off, and the order is frozen while the page is open, as on Platforms. The 50-row render cap stays, with a line under the last row saying how many more there are; in practice only the autogenerated kinds reach it. A kind that lists nothing says so. With the search empty and the owner toggle hiding some of the kind's collections, a second line says how many: "N from other users are hidden while Other users' collections is off"; with a search, or with nothing hidden, there is no such line.
The Favorites pane has no table. While the read is out it shows the spinner, and where it failed the failure, as every pane does; once it has answered, the sentence and the game count where the row stands for a collection, and otherwise only why the row is greyed — the sentence is about turning one on, and a greyed row has none.
In Steam counts how many of a collection's ROMs are already in Steam (CONTEXT.md → Reachable). It costs no RomM
request of its own. RomM's collection listings carry each collection's member ROM ids, on all three kinds, and the count
is those ids looked up against the rows Tender keeps — the test the sync uses when it files a collection member into a
Steam collection, so a member it counts is one turning the collection on files under a shortcut Tender already made. It
counts members, not shortcuts: two versions of one game in a collection count twice. It can fall short where Tender does
not yet know a member's version group — a member it has never fetched, or a row fetched before version groups were
recorded — until a sync fills that in. ROMs minus In Steam is still only roughly how many shortcuts turning the
collection on adds, because several new versions of one game become one shortcut. The same listings carry the owner's
user name on standard and smart collections, which is what the Owner column shows. get_collections forwards both —
in_steam_count on all three kinds, absent when Tender could not read its own record, and owner_username on standard
and smart, null where the listing lacks the field — and the member ids do not cross the wire. It costs the four RomM
requests get_collections makes — one each for standard and smart, and one for each of the two virtual types the plugin
syncs — and one read of Tender's own database after them.
Those two columns are why a kind is the pane and a collection a row. A pane per collection would show the owner and how many of its ROMs are already in Steam, and both fit as a column of the kind's table, so such a pane spends the whole detail on what one row already says.
Enable all / Disable all ask first only when the table lists more than 20 — with a search, when the search leaves more
than 20 (CONFIRM_ABOVE in collectionKinds.ts), on both buttons and on every kind; 20 or fewer are written at once,
with no dialog. It counts the collections listed, not those whose switch would change. With a search typed the dialog
names the collections that match rather than the whole table. When the dialog closes, on OK and on Cancel alike, focus
goes back to the button that opened it: Steam's dialog does not hand it back, and the pane's region would otherwise
follow focus to wherever it landed. They write exactly the collections the table lists, in one batch
(save_collections_sync over their ids): with a search, every collection the search leaves, those past the 50-row
render cap included; with none, the whole table. So on Franchises and on IGDB collections they write that type alone,
and with Other users' collections off no write reaches a collection known to be another user's — until Tender knows the
user's RomM id it cannot tell, and it writes what it lists, as the sync syncs it. Where the Favorites row stands for a
collection, the Collections write leaves that one out, since it is not in the table; where the row is greyed because
more than one favorites collection is a candidate, those are ordinary rows of the Collections table and the write
includes them.
The kinds' names reach the Steam names the by_label naming mode builds (CONTEXT.md → Collection naming mode), the
description of the Steam Library setting that turns that mode on, and the user guide: (Smart), (Franchise) and
(IGDB Collection), with (Autogenerated) as the fallback for a virtual collection of no known type; a standard
collection, favorites included, carries none. The rule, why it is shaped so, and the places the labels are spelled are
steam-non-steam-shortcuts.md § Collection naming mode. The keys on the wire and in
settings.json stay standard / smart / virtual.
A failed read is answered, and asked again. Where get_collections fails, every pane states the backend's message
in place of its table — or a sentence of its own where the failure carried none — with a line saying that leaving the
tab and coming back asks again, and the kind rows state a dash. Entering the tab asks again for a read that failed, and
for the owner toggle's setting where that read failed; a read that answered is kept, with its frozen order, for as long
as the page is open, and a read still out is not asked twice. While the collections read is out the list and the owner
toggle are already standing, and each pane shows a spinner where its table goes.
The four writes are a table row's switch, the Favorites switch, Enable all / Disable all, and the owner toggle, and they follow the rule at the top of § Library (Only the latest write speaks). Each is reported where it was made, and each place has a line of its own: a refused owner toggle or Favorites switch in the list column, under the owner toggle; a refused table switch or Enable all / Disable all in the pane, under the search line. The pane's line also goes when another row is selected. Selecting another row also clears the search, since a search is about the row it was typed on.
One more thing keeps an answer off the pane's line. A pane answer sets or clears it only while no other row has been selected since its write was issued, a round trip back to the same row included. So nothing lands on a pane entered since.
Settings¶
Wide, untabbed, list and detail: the sections on the left, the focused section on the right. Six sections: five hold what the narrow page stacked in eight — Registered Devices sits under Save Sync, SteamGridDB joins the other external service under Connections, and the save-sort migration the narrow page carried is gone, because each game now follows its own save directory the next time the plugin touches its saves — and Updates is new.
| Section | Holds |
|---|---|
| Connections | the services the plugin talks to: RomM (URL, account, Sign out, Allow insecure SSL) and SteamGridDB (the API key), one group each, titled by service. Home of every sign-in. |
| Save Sync | the toggle, device, before-launch and after-exit, default slot, history limit, Sync all now; then the registered devices as a table |
| Controller | Steam Input mode, Apply to all shortcuts, the input_driver fix. Home of the fix. |
| Steam Library | preferred region, collection games in platform groups, collection types in Steam names — the narrow page's Library section, renamed because a Library page now exists: the page is the RomM side (what is synced), the section is the Steam side (which version, in which groups, under which name) |
| Updates | installed and available version, "Development build — install updates with the installer." where this is a run from a checkout, the daily-check switch, Check now and what it found. Home of the update notice. |
| Advanced | log level |
The registered devices are the one thing on the page with more than two facts per row, so they are a table — Device, Client, Last seen — drawn with § Tables' shared one at the pane's default register. The layout study it was chosen from is device-list-layouts.html.
RetroAchievements is not here, and Connections is still its home. The plugin has no RetroAchievements account and no sign-in for it — building one is #1627, which also left the badge's home open between the game page, the retired System page and global settings. The section holds the two services that exist rather than a placeholder for the one that does not.
The section rows carry no control of their own, so the list is built with selectOnActivate: the activate handler that
adds is what makes a row a focus stop, and without it the list is neither walkable nor scrollable. Every read-only row
in a detail pane — a sign-in result, each row of the registered-devices table, the row naming this device — is a stop
for the same reason, since a pane scrolls only by moving focus and a group with no stop in it cannot be reached at all.
Two kinds of line are not, and for two different reasons. Content the region reveals on its own — above the pane's first
stop or below its last (ScrollRegion's revealEdge) — needs none, which is why the migration card carries no handler.
And the devices table's column header carries none under the Tables rule: the names accompany the rows below them
and a stop there would be a step that leads nowhere.
Settings' value inputs — RomM URL, custom headers, account, the SteamGridDB API key, default slot — each open a modal, because nothing on the page has to be seen while one is typed (Text input).
Two failures the narrow page carried are fixed here (#1020). A refused URL no longer sticks: the pending edit exists to carry a value across the remount that closing the modal can cause, so it is cleared whichever way the attempt ends, and the next open of the page shows the URL that was saved rather than the one that was rejected. And Apply to all shortcuts refuses a second press while a run is in flight — the guard is in the handler rather than only on the button, because a disabled control still reports a press on the device, and the button says which state it is in.
Data Management¶
Wide, list and detail — and the rows are not the operations. A row names a population, something this device holds, and states its numbers; the pane says what that population is, gives its numbers in full, and offers what can be done with it. Six rows, flat and ungrouped: Tender's shortcuts, Installed ROMs, Grid images, Other non-Steam games, Gone from RomM, Recovery bundles. The per-platform actions have left for Library › Platforms, and the platform modal with them.
That the rows are populations is a finding, not a preference. Arranging the five operations as a menu needs a name for the group they fall into, and every grouping of these five needs a category that exists only on screen. A category nobody can name is not a category: the rows were buttons, and a button says nothing about the device. The question a reader arrives with — what has this program put here, how much disk does it hold, what is left over — a menu of verbs cannot answer at all, while an inventory answers it before anything is pressed. The layout study the shape was chosen from is data-management-layouts.html. The list needs no headings for the same reason Settings needs none: six rows that each name a thing are their own order.
| Row | What it says on arrival | What the pane offers |
|---|---|---|
| Tender's shortcuts | total_shortcuts — the bound shortcuts Main counts every visit |
Remove all shortcuts |
| Installed ROMs | one count per install — two kept versions count twice | Uninstall all ROM files |
| Grid images | scan until asked — then how many are orphaned |
Remove the orphaned images |
| Other non-Steam games | Steam's own store less this plugin's entries | the whitelist, the removal, the RetroDECK guard |
| Gone from RomM | scan until asked — the server round trip |
Review, which opens the dialog below |
| Recovery bundles | how many are sealed and what they take | the bundles one by one, and nothing to press |
Every figure is reading, failed or answered, and its row's count slot shows which: a spinner while it is being read,
a dash where the read failed, the number once it is answered. The two scan rows add a fourth state before those three,
not asked, shown as scan. A pane never borrows one state's words for another: under a read still in flight it says
Reading… (a scan's own button says it is scanning and cannot be pressed again), and under a failed one it says what
could not be read and how to try again — reopening the page for a figure read on arrival, pressing the scan button again
for a scan.
A number that costs a round trip or a backend scan sits behind a press. Focus selects on this layout, so a reading
that rode on the selection would fire under every row the stick passes — a local scan of the grid directory for one row,
a RomM round trip for the other. Grid images and Gone from RomM therefore read scan until they are asked, and keep the
answer until something makes it wrong, because a stale number is worse than no number. A finished cleanup puts Gone from
RomM back to scan. Grid images goes back to scan after any removal of shortcuts from this page — Tender's shortcuts
once the backend has accepted, other non-Steam games, and a finished Gone from RomM cleanup — because each can leave
images the scan did not count: a Tender shortcut the plugin had no record of, or one whose removal report did not
complete (that report is what deletes a bound shortcut's images), a foreign entry whose art nothing here deletes, or a
cleanup run without recovery, which leaves its shortcuts' images in place. A grid-image removal re-derives its
candidates rather than taking the scan's, and answers how many it found beside how many it removed; the row reads 0
only when the two match, since then nothing orphaned at the moment of the removal is left. Fewer removed than found
means files that would not delete, and the row goes back to scan with the pane's status line saying how many; so does
an answer missing either count or removing more than it found, or no answer at all. A refused removal deleted nothing,
so the scanned count stands. The grid removal holds the page's busy state like the other removals, so no removal button
— its own included — can start another while it runs.
The whitelist's search sits inline, above the list it filters, which the pane expands under Configure whitelist — the search kind of text input, not the value kind that opens a modal (Text input).
Two rows would overlap if either were read naively, and the one that gives way is the foreign one. Tender's shortcuts are themselves non-Steam shortcuts, so a row counting Steam's store whole would report this plugin's own library a second time, under a heading saying Other — and its removal would take that library with it, since the whitelist protects by NAME and a synced library carries game names. So the foreign row is Steam's store less what this plugin created, told apart by what a shortcut launches rather than by what it is called, and the foreign row never removes ours: row 1 is where they go wholesale, the Gone-from-RomM cleanup takes the individual vanished ones, and a platform's own removal in Library takes a platform's. That reading is a per-shortcut sweep rather than a field: it takes time, so the row shows a spinner until it lands, and what it cannot establish it never offers — neither a store it could not read at all, nor an entry whose own reading did not arrive. The pane says which of the two happened. An unproven entry left alone is a row that under-reports; an unproven entry offered is a library deleted.
The size is the server's figure and never a walk of the disk. Rom.fs_size_bytes is what RomM reported for a ROM
(#1395), summed over the installed rows, so the page opens with a number instead of measuring for one — and it is
written with a ≈ for two reasons: an unpacked archive, a patch beside the original or extras in the same folder are
not the size the server named — a multi-file game is, which is why it is not on that list — and the field is NULL for a
row that predates its migration or belongs to a wholesale-skipped platform nobody has re-applied, so the sum understates
rather than fails. The cleanup goes on measuring the disk for itself, because its free-space line has to hold for a
bundle it is about to write rather than for a figure a server once reported.
The removed-games review stays a dialog, and the reason is geometry rather than inertia. A dialog may take up to 720
× 406 on the Deck — maxWidth: 720 against maxHeight: 76vh of the 534 px viewport, which is the right denominator
because the modal renders into findSP()'s document rather than inside the 454 px panel. A detail pane is 530 px
wide (806 px of content, less the 264 px list and the frame's 12 px gap — layout/Columns.tsx) and its height is what
an untabbed page's body is left with after whatever chrome sits above the page and the frame's Back-and-title row —
about 350 to 365 px on the Deck, depending on whether the 454 px container is taken as the scroller's own height or
the dev window's numbers above are carried across, which put the scroller some 14 px below the top of its view. Both of
the pane's numbers are derived from what is written here rather than measured on the device for this section, and the
margin is wide enough that the derivation would have to be badly wrong to change the answer: moving the review into the
pane would shrink it in both axes. Its rules do not change; they live in
removed-game-cleanup.md.
The dialog reads top to bottom as the options, the run's bar, then the candidates. The four options stand in two columns under the intro, each a short label with its full meaning in the line under it, and the acknowledgement a run without a recovery bundle asks for appears under the bundle's own toggle. The bar holds the recovery estimate against the free space at the target, Refresh free space, Load more while a page is still unloaded and the rule that every page is loaded before Confirm, Cancel and Confirm Cleanup, and while a run is going its progress and Stop Cleanup, then its result and the details region, and the line saying why Confirm would refuse a press. The bar sits above the table, not pinned under it as the layout study drew it: focus follows element order rather than the picture, so a bar after the rows is one press per row away however it is drawn — the Sync page keeps its buttons above its tables for the same reason. Load more is in the bar for that reason too: every row is a stop, and Confirm refuses until every page is loaded, so after the table it would be a table's length from the press it unblocks; the rows it adds land below it. The candidates are a table (Game, Platform, Verdict, Installed, Keep a copy). A verdict says what is known now and never the run's outcome — Gone from RomM for a candidate, Still on RomM for a version listed only because a whole-game removal could take it, each with one of 4 where the game has more than one version. A version still on RomM is removed too if the run's check finds every version of its game gone, so neither a verdict nor the section's heading (Other versions of these games — still on RomM) says kept: either would promise more than the preview knows, and a heading is read before the line that qualifies it. The full sentence behind a verdict is the cell's title, which is a mouse's way to it only: a tooltip is a hover the controller cannot perform, so on the controller the intro and the other versions' section line say the same. For the same reason what tells two versions apart is on screen: under each row a line names the version — its ROM id, and its file name where that says more than the game's name — because two gone versions of one game otherwise draw as identical rows, each with a toggle of its own. The other versions are a section of the same table under a heading whose line states the sentence for all of them. Keep a copy is the per-row toggle that puts a version's installed ROM content in the recovery bundle: an installed row carries it and it is that row's focus stop, since a second stop on the row would be a dead step in front of it; a row with nothing installed carries a dash and is a stop itself, so the table can be walked. A row's warnings — a field shortened for the wire, the preview's own warning, and the ROM file that goes without a backup — are lines under it, inside the row. The dialog is revealed at both edges: focus reaching the first option scrolls it to its top, so the intro comes back, and focus reaching the last row scrolls it to its end, so that row's own lines and the note after the table are not left under the view. Steam scrolls only far enough to reveal the focused control, which is why both are needed. The suite pins that each scroll is asked for; whether it lands where it should is device-only.
Recovery bundles are listed and nothing here removes them. The row states how many are sealed and what they cost, so
the disk they take stops being invisible, and the pane lists them under that total as a table — Game, Sealed, Size —
newest first. Game and day (UTC) are read off the folder's name alone — no file's contents and no seal are read for them
— so the game is spelled the way the folder spells it (Shenmue-II), and a folder not in the shape Tender writes — one
renamed by hand, or one ending .durability-uncertain because its seal could not be confirmed — is listed under its own
name with no day, after every dated one. Each row is a focus stop with nothing to press, because the pane scrolls only
by focus. Deleting one is the reader's own business in a file manager until a cut gives that action a home. A row that
shows something and offers nothing is still a row this page owes, because the page's claim is what this device holds.
Downloads¶
Narrow and unchanged: the active rows with progress, their Pause / Resume / Cancel as rows below the list, the finished rows, Clear Completed. Reached through View All on Main while the queue is not empty; an empty Downloads page has no menu entry.
One home per action¶
| Action | Today | Target |
|---|---|---|
| Start a sync | Sync | Sync; Main starts none, but keeps Cancel |
| Review and apply a preview | Sync, as a table | Sync, as a table |
| Force Full Sync, Skip preview | Sync | Sync |
| Restart Steam now (session budget) | Sync | Sync; Main shows the notice |
| Sync a platform on or off | Library | Library › Platforms |
| Choose the emulator core | Library › Platforms | Library › Platforms |
| Download BIOS files | Library › Platforms | Library › Platforms |
| Delete BIOS files | Library › Platforms | Library › Platforms |
| Remove one platform's shortcuts | Library › Platforms | Library › Platforms |
| Delete one platform's save files | Library › Platforms | Library › Platforms |
Fix the RetroArch input_driver |
Settings › Controller | Settings › Controller; Main shows the notice |
| Pause or cancel a download | Downloads | Downloads |
| Clean up removed RomM games | Data Management, modal | Data Management › Gone from RomM, reviewed in a dialog |
| Check for a newer release | Settings › Updates | Settings › Updates; Main shows the notice |
Sequence¶
The pages land in this order under #1808, each with the open work that already sits in its file:
- The wide frame (#1813) — the two levers and their clearing paths, the Back row, tabs, list and detail, the measured height. No page uses it yet; it is the one piece that rests on Steam internals and is reviewed on its own.
- 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.
- 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.
- Settings (#1816) — the sections, Steam Library, the
homes for the
input_driverfix and the save-sort migration with their notices on Main. Carries #1020's URL and double-press fixes. Landed, in one PR; RetroAchievements has no sign-in for Connections to hold until #1627. - 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
ConfirmModalthat 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
Fieldshape with Last seen right-aligned. The table is what shipped, on the axis the Deck is short of — one row per device instead of two, and eight devices costing nine rows rather than sixteen — and the platform and the shortened id are dropped with it. It records its own objection, and half of it has since been answered: the table is no longer an implementation of its own — it is § Tables' shared one, the same component the BIOS files and the preview draw. What still stands is what the study actually says: it remains the only content on that pane with column headers, and that ends when the rows around it are written against the pane primitives too, which is not done. Like the studies above it is a record of a choice, not a description of the page. - The static prototype the decisions were made on: qam-prototype.html, a single
self-contained page kept in
docs/assets/. Every page at device size with numbered notes; its example data is invented, and it reflects the decisions as of this page's first version — the save-sort notice and its Settings home it draws no longer exist, and the device-list study above draws the same notice. Redrawn to the Deck's real 854 × 534 CSS px — Steam's 80 px top bar over a 454 px panel — once the device round had measured them. - ADR-0029 — why the panel widens through Steam's Friends expansion and not a full-screen route, and what that rests on.