15 KiB
Glass Shell lab guide (for anyone, human or agent, working on the theme)
Glass Shell restyles the Steam Frame's VR interface. That interface is Steam's gamepadui running as SteamVR overlays. It does this by injecting CSS into the running Steam client over its devtools socket (127.0.0.1:8080 on the headset, enabled because Steam runs with -cef-enable-debugging). Nothing is persisted: a Steam restart or a reboot always brings back the stock UI.
All commands run from glass-shell/ on the PC:
python glass.py <command>
The driver reaches the headset over SSH via the steam-frame-ssh skill helper.
Surfaces
Each surface is a separate Steam popup window and a separate SteamVR overlay quad with per-pixel alpha. Transparent pixels show passthrough (the room) or the running game.
| Surface | Size (CSS px) | What it is |
|---|---|---|
main |
1280×720 @1.5x | The dashboard window: library, store chrome, settings, friends, game pages, running-game page. Most routes live here |
bar |
1200×80 | The dashboard bar (dock): Steam button, open windows and apps, + (Add Desktop Window / Launch Program), small buttons, Quick Access (clock and status), avatar |
barpopup |
300×1024 | Popups that open from bar buttons, such as the + list and other bar menus |
frame.menu |
300×800 | Menus that open from window frames, e.g. the frame controls under the main window |
floatingfooter |
600×40 | Button legend under the window, such as "LB RB Hide Laser Mouse" |
tooltip |
400×40 | Bar tooltips |
volumelevel |
250×100 | Volume HUD |
notifications |
340×80 | Toast notifications |
keyboard |
854×280 | VR keyboard |
python glass.py surfaces lists them live. New popup ids appear over time (frame.menu.6256000N); aliases match by prefix.
Tokens instead of hashed classes
Steam's class names are hashed (_2Y_MrEdtYx5M51OJoFSLB). Theme CSS uses readable tokens that resolve at injection time from Steam's webpack CSS modules, so they keep working after Steam updates.
| Form | Meaning |
|---|---|
%{Name} |
The class exported as Name (must be unique) |
%{Anchor>Name} |
Name from the module that also exports Anchor |
%{*Anchor>Name} |
Every variant. Steam ships several builds of some modules (e.g. 4 gamepaddialog builds); expands to :is(.a,.b,…) |
Literal classes Steam uses unhashed are written as normal CSS: .gpfocus, .gpfocuswithin, .Panel, .Focusable, .GamepadMode, .BasicUI, .VRDashboardBarSmallButton, .VRDashboardBarSmallButtonActive.
To find tokens:
python glass.py outline SURF [--route R] [--sel S] [--max N]prints the live DOM with every class already written as its token, plus rects, background hints and text. Copy tokens from it.python glass.py classes REGEXsearches readable names (e.g.classes "^Field").python glass.py statuslists tokens that did not resolve. Your files must have none.
Theme files
theme/*.css are bundled in name order.
- Files not named
*.nowrap.cssare wrapped ashtml.lgs-on { … }using CSS nesting (Chrome 126).- Write normal selectors; they become
html.lgs-on <selector>and gain(0,1,1)specificity over Steam's rules. - To target
<html>itself use&; to targetbodywritebody.GamepadMode ….
- Write normal selectors; they become
@keyframes,@font-faceand@propertymust live in*.nowrap.cssfiles (they cannot be nested).theme/defs.svg(optional) is inserted into every window as hidden SVG defs, forfilter: url(#id).00-tokens.nowrap.cssholds the design tokens (colours, materials, radii, motion). Use its variables; don't hard-code new colours.
The edit → see loop
python glass.py sync # upload theme/ (and tools)
python glass.py shot main lib_home_after --route /library/home --theme on
python glass.py shot main lib_home_before --route /library/home --theme off
Then open shots/lib_home_after.png with the Read tool. Screenshots are 1.5× (1920×1080 for main) and transparent where the overlay is transparent. Transparent areas look black or checkered, but in the headset they show the room.
shot SURF NAMEbrings that surface to the front (it renders even when nobody wears the headset), optionally navigates (--route), runs JS (--pre), waits--settleseconds (default 1.2) and captures.--theme on|offreloads or removes the theme inside the same locked step. Useonfor after-shots andofffor before-shots.--preJS runs in Steam's SharedJSContext withL= the lab helpers:L.nav('/route'),L.back(),L.route()L.click('bar', '%{AddWindowButton}'): a synthetic pointer click that opens menus and popupsL.q(surface, sel),L.qa(surface, sel),L.surface('main')(the window object)L.clickText(surface, sel, 'Label'): click the match whose text isLabelawait L.pad('down', 3): move controller focus like a D-pad (up/down/left/right only), then returnL.focused(). Focus states are only visible this way: DOMfocus()does not move Steam's focus. It refuses left/right while a slider is focused, because that would change its value. Still avoid left/right on settings pagesL.focused(): where.gpfocusis on each surfaceawait L.sleep(ms)- Chain with commas:
--pre "L.nav('/settings'), L.click('main', '%{Some>Tab}')" - Or use an async IIFE when you need waits:
--pre "(async()=>{L.click('bar','%{AddWindowButton}'); await new Promise(r=>setTimeout(r,500)); return L.route()})()"
python glass.py audit SURF [--route R] [--pre JS]diffs stock against themed for every interactive element and text run:- GONE / HIDDEN / SHRUNK / UNCLICKABLE: functionality regressions. These must be zero.
- CONTRAST: text that became less legible than stock and is below 4.5:1 (3:1 for large text), computed over a grey, a bright and a dark room behind the glass.
moved >24px: informational.
python glass.py perf SURF [--route R] [--pre JS]scrolls the surface's main scroller with the theme off, then on, and prints fps, median, p95 and long frames for each.python glass.py styles SURF "SELECTOR"prints key computed styles of matches.python glass.py js "EXPR"evaluates in SharedJSContext withL.
Sharing the headset
The UI is live and shared with other agents and with the user, who may be wearing the headset.
- Use only the atomic forms:
shot --route/--pre,outline --route,audit --route/--pre. They hold a device lock across navigate, act and capture. Never assume the route survives between two commands. syncuploads the wholetheme/folder from this PC, so it includes other agents' in-progress files. That's expected.- Prefix screenshot names with your area (
bar_…,lib_…,set_…).
SteamVR's own pages (vr: surfaces)
Part of the Frame's interface is drawn by SteamVR, not Steam: vrwebhelper pages served from 127.0.0.1:27062/dashboard/. The SteamVR developer setting VRWebHelper/DebuggerEnabled exposes them on devtools port 8090; it was enabled with the user's approval.
| Surface | What it is |
|---|---|
vr:systemui |
A 1860×2048 atlas page: SteamVR's scene graph lays out several panels (vsg-node) on one page, and each becomes its own VR quad. It holds the window grab bar (%{GrabHandleButton}/%{GrabHandleBar}), resize handles, the frame controls under the dashboard window (%{FrameControlsContainer}: keyboard, pop-out, recenter, more), controller battery status and, while a scene app runs, the Now Playing panel (Resume Game / VR Controller Bindings / VR Video Settings / Exit Game). Other panels appear on demand |
vr:controllerbindingui |
The SteamVR controller binding editor (app list, binding pages) |
| others | keyboard.html, messageoverlay.html, notificationtoast.html, bindingcallouts.html and others appear while they're shown. python glass.py surfaces lists the live ones |
- Every lab command takes
vr:PAGEas the surface:shot,outline,styles,audit,perf,click. - For
jsadd--in vr:PAGE, e.g.python glass.py js "L.classes('^Grab')" --in vr:systemui.Lworks inside the page (outline, snap/diff, classes); Steam-only helpers (nav, pad, mark/restore) don't. %{Token}s resolve from SteamVR's own webpack bundle (webpackChunkvrwebui), so the same token syntax works.- Theme files for SteamVR pages live in
theme/vr/*.css. They're bundled after the sharedtheme/*.nowrap.csstokens. Wrapping, the.nowraprule and brace checks work the same way. vr:steps use their own device lock (lab-vr.lock), so they don't wait on Steam route captures.--theme onthemes only the SteamVR pages.--theme offstops thelgs-vrwatcher and strips them.
- For users,
lgs onstarts the transient unitlgs-shell(P8), which keeps new SteamVR pages themed and runs the native layer when it is on.lgs offstops it at once; a theme off withoutlgs off(a lab--stockstep) makes it dormant until the theme is back (docs/phase2/contracts/daemon.md§1). It never survives a reboot or a Steam restart. - Now Playing only exists while a scene app runs. The only app you may start for this is the user's own Liquid Glass Frame showcase:
- start it with
python ../.claude/skills/run-liquid-glass-frame/driver.py start, and only ifdriver.py statusshows it isn't already running (another session may be using it) - stop it with
driver.py stopwhen done - never press Resume Game / Exit Game / VR Video Settings / VR Controller Bindings on it
- start it with
Gotchas found while mapping (read before styling)
- Focus and selected fills are keyframe animations. Steam paints
.gpfocus, FieldHighlightOnFocus, DashboardMenu items, QAM tabs and friend/download rows with@keyframes … forwards(ItemFocusAnim-*). Animated values beat normal declarations, so a themebackground/coloron those states is silently ignored. Put!importanton the focus or selectedbackground/color/box-shadowdeclarations (an!importantauthor rule beats an animation). - Keyboard focus is
%{Modal>Focused}(virtual focus), not.gpfocus. - The notifications window body has only
.LowPerfMode. Don't scope toast rules underbody.GamepadModeor.BasicUI. - Popups are clipped.
- Every popup card sits in
%{PopupContent}(overflow hidden, sized to content). SteamVR also clip-rects tooltip, volume and footer to it, and the bar is exactly its 80 px window. - Outer shadows and glows get cut, so use inset rims and sheens.
- Opaque
#0e141bbox-shadow "fades" at list or scroll edges show as dark bars on translucent glass. Restyle them to transparent-to-glass gradients or remove the colour, without touching geometry.
- Every popup card sits in
- The window background is painted several times:
%{BasicHome}%{OpaqueBackground},%{GamepadLibrary},%{GamepadSearch},%{DownloadsPage},%{GamepadPage}%{DialogBackground},%{ContentManagement}, and%{TrueBlackBackground}(/chat). Each must become transparent so the window glass shows. Shell owns the root; area owners clear their own page roots. - Steam-owned pseudo-elements and transforms are listed in each inventory. Don't add your own
::before/::afterwhere Steam already uses one, and never touch transforms or inline geometry: toggle knobs, slider positions, carousels, virtualized rows, the bar tab indicator, focustranslateZ. - Synthetic dialogs and menus built from Steam's own components (no-op handlers) are in
docs/inventory/shell.md§0.2 (CONFIRM, ALERT, MENU, SUBMENU, POWER, ZOO). Use them to style and verify modals and context menus without side effects. - Each locked step cleans up after itself.
shot,audit,outline,clickandjsall close the menus, dialogs and bar popups the step opened, and leave anything open before the step alone. Add--keepto leave them open, which is rarely needed. glass.pyundoes Git Bash's/routemangling and prints UTF-8, soMSYS_NO_PATHCONV/PYTHONIOENCODINGare no longer needed (harmless if set).- Steam's High Contrast (
prefers-contrast: more) and Reduce Motion (prefers-reduced-motion) accessibility settings must keep working. The tokens raise the dial to fully opaque under high contrast; don't fight that.
Hard rules (functionality must be retained)
Styling: only look-and-feel properties. Use colour, background, border, radius, shadow, backdrop-filter, filter on decorative parts, text colour/shadow, small padding tweaks, and transitions on state changes.
Never do these to Steam's elements:
- Never
display:none,visibility:hidden,opacity:0(or near 0),pointer-events:none,content-visibility, orclip-paththat hides content. - Never change
position,overflow,width/height,flex/gridlayout of containers,z-indexstacking that could cover controls, or transforms that Steam animates or positions with.
Our own pseudo-elements need pointer-events:none. Only add them where Steam doesn't already use that pseudo-element; check with styles first.
Interaction states must stay distinguishable:
- Focus (
.gpfocus, which is how controller and laser focus show) must be obvious: fill plus ring. - Selected states use the white "selected" fill.
- Disabled states stay dimmer.
- Hover must stay visible.
Hit targets never shrink. The audit's SHRUNK must stay empty.
Text must stay legible on glass. The audit's CONTRAST must stay empty for your area.
Performance: the headset GPU is shared with the compositor. Check with python glass.py perf SURF --route R: themed must match stock fps (within 5%) with no new long frames.
- Prefer
backdrop-filteron floating elements over in-page content (menus, sheets, headers, capsules). Avoid adding it to every list row or card. - No
filterorbackdrop-filteranimations and no infinite animations. - Animate opacity and the independent
scale/translateproperties only. Don't animatetransform, which Steam uses for positioning.
Things you may and may not do on the live UI
Allowed:
- Navigating routes.
- Opening menus, popups, tabs and dialogs with
L.clickto see them. - Closing them with
L.back()or by navigating away. - Screenshots.
- Theme on and off.
Never:
- Launch games or programs.
- Change settings values: toggles, sliders, dropdown choices.
- Confirm dialogs.
- Send messages or friend actions.
- Purchase anything.
- Sign out.
- Power actions: shutdown, restart, suspend.
- Exit or stop a game.
- Uninstall or delete anything.
- Save, keep or upload frames of the room. That covers
/dev/video99andhvgrabcaptures, and glassd--dump-roomand--dumpoutputs while the feed is live. Look at them, then delete them at once, on the Frame and locally.- glassd itself may read
/dev/video99in memory. The user asked for real glass over the room in phase 2. - Do not run other camera readers.
- glassd itself may read
If a menu item would perform an action, look at it, don't click it.