# Capability T3: new first-class views inside Steam's gamepadui ("steam-react") **Status: proven on the live Frame**, 2026-10-07. Steam client build `11094443` (the constant every webpack module carries; the Phase 1 inventories were mapped on `11041156`), React **19.1.1**, react-router **v5**, Chrome 126. Prototype: `device/proto/react_proto.js` (one JS expression; it defines `window.__LGS_PROTO`). ## 1. Result We can add new views to the main VR window that are as native as Steam's own pages, entirely in memory: - **A new route.** One React fiber is patched: the component that holds the main window's list of top-level `` elements. Its `type` is swapped for a wrapper that adds our `` element in front of Steam's. No Steam source is edited and nothing is written to disk. - **Replacing what an existing route renders.** The same wrapper can clone Steam's `` element for a path (for example `/library/home`) and give it different children. This is the equivalent of Decky Loader's `routerHook.addPatch`. - **Steam's own building blocks.** The page uses only Steam's components: `GamepadPage`, `Focusable` with `flow-children`, `DialogButton`, `ToggleField`, `Menu`/`MenuItem` with `showContextMenu`, and Steam's own data hooks. - D-pad focus, A, B, the Options (≡) button, the footer legend, laser clicks, focus memory, Steam's header and transitions all work with no extra code. - **Steam's data.** Installed games come from `collectionStore` and art URLs from `appStore`. The "+ > Launch Program" list comes from Steam's own `ScanForInstalledNonSteamApps` hook and Steam's own filter lists. - **Clean removal.** Removing restores the fiber types, the history listener and the global. A Steam restart or reboot also returns the stock UI, because nothing exists outside SharedJSContext's JS heap. The selftest (section 6) passes 19 of 19 checks, run twice in a row, and each run ends with a clean removal. ## 2. Evidence All shots are `main`, 1920x1080 (1.5x). | Shot | What it shows | |---|---| | `shots/p2_react_proto_home.png` | Final prototype at `/library/lgs/proto` with the Glass Shell theme **off** (stock Steam colours): 3 installed games plus 3 Launch Program entries as circular tiles, the first tile focused, and a footer legend with "≡ Options / A Open (logged only) / B Back" taken from our action descriptions | | `shots/p2_react_proto_libroute.png` | The same page with the theme **on**, `noFocusRing` on the tiles (only the disc ring shows focus) | | `shots/p2_react_proto_focus.png` | Controller focus moved to row 2 with L.pad-style D-pad events (theme on; an older build without `noFocusRing`, so Steam's rectangular FocusRing is visible too) | | `shots/p2_react_proto_menu.png` | The Options (≡) button on a tile opens a **Steam context menu** built from Steam's `Menu`/`MenuItem` (themed as glass by Phase 1 CSS) | | `shots/p2_react_proto_home_override.png` | `/library/home` rendering our page instead of Steam's Home (route-render override). Steam's Home came back after `clearOverrides()`/`remove()`: 774 DOM nodes again | | `shots/p2_react_proto_error_guard.png` | Our error boundary catching a forced render error. Steam's header and footer stay intact and Back is focused | | `shots/p2_react_proto_after_remove.png` | An early `/lgs/proto` build after removal, navigated to again: an **empty page** (header and B Back only). This is why the default route moved under `/library` | | `shots/p2_react_proto_removed_fallback.png` / `p2_react_libfallback_probe.png` | `/library/lgs/proto` after removal (and before any install) falls back to **Steam's own library page** | ## 3. Recipe ### 3.1 webpack require ```js let req; window.webpackChunksteamui.push([[Symbol('lgs')], {}, (r) => { req = r; }]); // req.m = { id: factory } (2618 factories in this build); req(id) = exports. // req.c (the module cache) is NOT exposed, so you cannot list loaded modules. ``` **Never call `req(id)` on every module.** `require()` of a module that nobody has loaded yet runs its top-level code. Filter by source text first, then require only the single module that defines what you need: ```js function resolve(FINDERS) { // FINDERS: name -> [needles[], pick(exports)] const cand = {}, size = {}; for (const k in FINDERS) cand[k] = []; for (const id of Object.keys(req.m)) { const s = Function.prototype.toString.call(req.m[id]); for (const k in FINDERS) if (FINDERS[k][0].every((n) => s.includes(n))) { cand[k].push(id); size[id] = s.length; } } const out = {}; for (const k in FINDERS) for (const id of cand[k].sort((a, b) => size[a] - size[b])) { const v = FINDERS[k][1](req(id)); if (v) { out[k] = v; break; } } return out; } ``` `pick()` helper: an export's source is `Function.prototype.toString` of the function, of `.render` for a forwardRef, of `.type` for a memo, or of `prototype.render` for a class (see `fnSrc` in the prototype). ### 3.2 Module finders (source text, not ids) Every finder below matched **exactly one** module live (`__LGS_PROTO.resolve()` prints the candidate counts). The ids are listed only so you can check them. They did not change between builds `11041156` and `11094443`, because webpack ids are deterministic, but **do not hard-code them**. | Name | Needles in the module source | Pick | Id (11094443) | |---|---|---|---| | React | `react.production` | exports with `createElement`, `useState`, `version` | 51745 (re-exported by 63696) | | jsx runtime | `react-jsx-runtime.production` | `{jsx, jsxs, Fragment}` | 3326 (via 62540) | | ReactDOM | `react-dom.production` | `createPortal`, `flushSync` (`createRoot` is in `react-dom-client.production`, 98131) | 65473 | | react-router v5 | `computeRootMatch`, `Router-History` | `Route` = class whose `prototype.render` has `computedMatch` and `.component`; `Switch` = `Children.forEach`; `matchPath` = function with `isExact` | 49519 | | Route paths table | `"/zoo"`, `GamepadUI` | the object with `GamepadUI.Zoo.Root()` and `Library.Home()` (Steam's `Routes`) | 80344 | | **Focusable** (Panel) | `"flow-children":`, `focusWithinClassName`, `"Panel"` | function whose source has `"flow-children"` | 38850 | | **DialogButton** | `"DialogButton","_DialogLayout","Secondary"` | forwardRef with that string | 76982 | | DialogButtonPrimary | `"DialogButton","_DialogLayout","Primary"` | the same | 76982 | | Field | `spacingBetweenLabelAndChild`, `"keep-inline"` | function with both | 23477 | | **ToggleField** | `"ToggleField"` | forwardRef with `"ToggleField"`; it looks up the gamepad flavour through Steam's component-provider context (`Zt("ToggleField")`) | 76982 | | SliderField | `"SliderField"` | short function with `"SliderField"` | 50777 | | DropdownField | `"DropDownField"` | short function with `"DropDownField"` | 50777 | | **GamepadPage** | `useHeaderOpacitiesForGamepadPage`, `padForHeader` | forwardRef with `padForHeader`, `scrollable`, `headerVisibility` | 92275 | | ScrollPanel | `scrollPaddingLeft`, `ScrollBoth` | forwardRef with `scrollPaddingTop`, `scrollDirection` | 52872 | | ScrollPanelGroup (gamepad scrolling) | the same plus `scrollStepPercent` | forwardRef with `scrollStepPercent`, `onGamepadDirection` | 52872 | | ConfirmModal | `bAlertDialog`, `strMiddleButtonText`, `bProgressDialog` | function with `bAlertDialog`, `strMiddleButtonText`, `closeModal` | 30583 | | showModal | `bHideMainWindowForPopouts`, `bForcePopOut` | short async function | 12302 | | showContextMenu | `GetContextMenuManagerFromWindow`, `CreateContextMenuInstance` | function with `CreateContextMenuInstance` and `.Show()` | 377 | | Menu, MenuItem, MenuSeparator | `contextMenuCheckMark`, `bInteractableItem` | MenuItem = class with `bInteractableItem` and `OnOKButton`; Menu = short function with `labelId:` and `useId()`; Separator = `ContextMenuSeparator` | 12711 | | useNonSteamApps (hook) | `ScanForInstalledNonSteamApps`, `useEffect` | the function that calls it | 68472 | | Launch Program filters | `"vrurlhandler"`, `"Install Chromium"` | arrays: always-hidden `['steam','vrurlhandler']`; hidden unless developer mode `['firewall-config',…,'konsole',…]`; always shown by name `['Install Chromium']`; hook `useDevMode` = function with `"developer_mode_enabled"` | 5757 | **Rule:** if a finder ever gets more than one candidate, tighten its needles before using it. Each extra candidate is a module that might get executed early. `install()` **fails closed**: it throws before patching anything if a required finder (React, jsx, RoutePaths, Focusable, DialogButton, GamepadPage) misses. **mobx `observer()` components hide their source.** Their fiber `type` is a generic wrapper (`function(Qe,st){return Oe(function(){return Ue(Qe,st)},Pe)}`). Find those instances by **props shape** instead. Example: the bar's "+" button component is a memo fiber (tag 15) whose props are exactly `{allowLaunchProgram}`, 7 levels above `%{AddWindowButton}`. The same `type`-swap patch works on it. ### 3.3 The main window's route switch Live structure (module 37730): ``` Y8 (observer: AppDetailsMain panel, FocusNavHistory) -> ErrorBoundary(errorKey = location.key) -> v8 = memo(() => <> { ~31 top-level routes: Oobe, Login, …, Library.Home, AppProperties, Library.App, Library.Root, Settings.Root, Search.Root, Zoo.Root, About, …, ["/","/index.html","/sp.html"] } { AppRunning, Keyboard, AppOverlay } // overlay routes ) z6(props): loc = useLocation(); [el, match] = first child whose props.path (or from) matches (matchPath); renders TransitionGroup(className TopLevelTransitionSwitch) > transition(navKey = match.path) > cloneElement(el, {location, computedMatch}) ``` - Route elements are Steam's wrapper `Jh` (module 72500), a `` that adds a "route disabled" context, or `Pc`, which shows "Loading" until services are initialised. - **Reuse the element type of Steam's own `/zoo` route** for our route. Then our page gets exactly the same wrapper and transitions. Find the switch structurally (no names): walk the fiber tree from the main window's React root. The match is the **function component** (tag 0) whose `memoizedProps.children` array contains both the `/settings` and `/zoo` routes: ```js const inst = SteamUIStore.WindowStore.VRGamepadUIMainWindowInstance; const doc = inst.BrowserWindow.document; // main window ("VR_uid0") const el = [...doc.body.querySelectorAll('*')].find((n) => Object.keys(n).some((k) => k.startsWith('__reactFiber$'))); let f = el[Object.keys(el).find((k) => k.startsWith('__reactFiber$'))]; while (f.return) f = f.return; // HostRoot // DFS child/sibling; match f.tag === 0 && Array.isArray(f.memoizedProps.children) // && children.some(c => c?.props?.path === Routes.Settings.Root()) // && children.some(c => c?.props?.path === Routes.GamepadUI.Zoo.Root()) ``` Popups (bar, barpopup, frame.menu, …) are portals of SharedJSContext's single React root. The same walk therefore reaches every surface. Exactly one switch was found. ### 3.4 Adding a route: patch the fiber's `type` ```js function patchFiber(f) { const orig = f.type; const patched = function LgsRouteSwitch(props, second) { let children = [].concat(props.children); children = children.map((c) => { // 3.5: route-render overrides const fn = c && c.props && overrides.get(c.props.path); return fn ? React.cloneElement(c, { children: fn(c.props.children) }) : c; }); if (ourRouteEl) children = [ourRouteEl].concat(children); // first, so nothing shadows it return orig.call(this, { ...props, children }, second); // same hooks, same order }; patched.__lgsOrig = orig; f.type = patched; if (f.alternate) f.alternate.type = patched; // both current and work-in-progress } const RouteType = f.memoizedProps.children.find((c) => c?.props?.path === '/zoo').type; ourRouteEl = jsx(RouteType, { path: '/library/lgs/proto', children: jsx(OurPage, {}) }, 'lgs-route'); ``` Why this works and stays put: - React's `createWorkInProgress` copies `type` from the current fiber. `z6` re-renders on every navigation because it uses `useLocation()`, so the next route change runs the wrapper. - `elementType` is unchanged, so the parent's reconciliation (`v8` is a memo that never re-renders) keeps reusing the fiber. - The wrapper calls the original as a plain function inside the same render, so the hook order is identical. **Self-healing.** If React ever remounts the switch (a new fiber with the original `type`), the patch is gone. The prototype keeps a `m_history.listen()` callback that re-patches before the router renders into our path (`ensurePatched()`). For overrides of Steam routes the same idea applies: re-check on every navigation. A lost override degrades to Steam's own page, which is harmless. **Route path choice.** - Put new routes under **`/library/lgs/…`**. After removal, a stale history entry (Back, or Steam's backstack) then lands on Steam's `/library` route, which renders the stock library. That is shown in `p2_react_proto_removed_fallback.png`. - A top-level `/lgs/…` path renders an **empty page** after removal (`p2_react_proto_after_remove.png`). B still works there, so it is not a trap, but it is a dead end. - **Side effect of the `/library` prefix.** Steam's `BRouteMatch(Library.Root())` rules (window composition, header opacity lists in modules 56084 and 84978) treat the page as a library page. Live, the header (Back + search) and footer looked identical on both paths. ### 3.5 Replacing what an existing route renders ```js overrides.set('/library/home', (steamChildren) => jsx(OurHome, { steamHome: steamChildren })); inst.Navigate('/library/home', true); // replace; re-renders the switch so the override applies // restore: overrides.clear(); if on the path, inst.Navigate(path, true) ``` - `fn` receives **Steam's original children**. A replacement can therefore keep Steam's page reachable, for example by rendering `steamChildren` on another route such as `/library/lgs/steamhome`, or inside a tab of the new view. This is how "all functionality retained" can hold when a whole page is redesigned. - Verified live: our page rendered at `/library/home` with controller focus on the first tile. `clearOverrides()` brought Steam's Home back (774 nodes). ### 3.6 Building the page from Steam's components ```js jsx(GamepadPage, { scrollable: false, // false: a Focusable root; true: a ScrollPanel root children: jsxs(Focusable, { onCancel: back, onCancelActionDescription: 'Back', children: [ jsx(Focusable, { 'flow-children': 'grid', children: items.map((it, i) => jsx(Focusable, { autoFocus: i === 0, noFocusRing: true, onActivate: () => open(it), // laser click AND gamepad A onMenuButton: (e) => showContextMenu(menuEl(it), e.currentTarget, {}), onOKActionDescription: 'Open', onMenuActionDescription: 'Options', onGamepadFocus: () => …, children: … }, key(it))) }), jsxs(Focusable, { 'flow-children': 'row', children: [ jsx(DialogButton, { onClick: back, children: 'Back' }), jsx(ToggleField, { label: 'Show labels', checked, onChange: (v) => … }) ] }) ] }) }); ``` Reference, read from the live sources: - **`GamepadPage` props:** - `padForHeader` (default true), `padForFooter` (true) and `flexed` (true). - `headerVisibility`: `'opaque'` (default), `'default'`, `'fadeInOnScroll'` or `'fadeInBackgroundOnScroll'`. Also `minimumOpacity`. - `scrollable` (default true, which renders a ScrollPanel), `background` (`'dialog'` adds Steam's dialog gradient; omit it for a transparent page) and `contentMaxWidth`. - **`Focusable` (Panel) navigation props**, split by module 47428: - `autoFocus`, `preferredFocus`, `focusable`, `focusableIfEmpty`, `childFocusDisabled`, `fnCanTakeFocus`, `navRef`, `navKey`, `noFocusRing`, `focusRingSizeElementID`. - `onFocusWithin`, `resetNavOnEntry`, `navEntryPreferPosition`, `scrollIntoViewWhenChildFocused`, `scrollIntoViewType`, `onMoveUp/Right/Down/Left`, `actionDescriptionMap`. - Plus `flow-children`, `onActivate`, `onCancel`, `focusClassName` and `focusWithinClassName`. These are extra classes; the nav system always sets `gpfocus` and `gpfocuswithin`. - **Gamepad events** (module 93046): - `onButtonDown/Up`, `onOKButton`, `onCancelButton`, `onSecondaryButton` (X), `onOptionsButton` (Y), `onMenuButton` (≡ Start), `onGamepadDirection`, `onGamepadFocus`, `onGamepadBlur`. - Footer labels come from `on{OK,Cancel,Secondary,Options,Menu}ActionDescription`. - **`flow-children`:** `row`, `column`, `row-reverse`, `column-reverse`, `grid`, `geometric` (module 96485). Anything else asserts. - **EGamepadButton** (module 20505): OK 1, CANCEL 2, SECONDARY 3 (X), OPTIONS 4 (Y), BUMPER_LEFT 5, BUMPER_RIGHT 6, TRIGGER_LEFT 7, TRIGGER_RIGHT 8, DIR_UP 9, DIR_DOWN 10, DIR_LEFT 11, DIR_RIGHT 12, SELECT 13, START 14 (≡). - **Menus:** - `showContextMenu(jsx(Menu, {label, children:[jsx(MenuItem,{onSelected, tone?, disabled?, children})…]}), anchorEl, {})` returns an instance with `.Hide()`. - In the main window it renders as Steam's centred gamepad sheet, and Steam appends a Cancel item. - `SubMenu`, `CheckItem` (`bChecked`) and `Separator` live in the same module (12711). See `docs/inventory/shell.md` §0.2. - **Modals:** - `showModal(jsx(ConfirmModal, {strTitle, strDescription, strOKButtonText, strCancelButtonText, onOK, onCancel, bAlertDialog?, strMiddleButtonText?, onMiddleButton?}), mainWindow)` returns an instance with `.Close()`. - A modal is an alternative container for an overlay panel (Control-Center-style tiles) that needs no route patch. - **Styling:** a `