@@ -27,8 +27,10 @@
}
}
* { box-sizing: border-box; }
* { box-sizing: border-box; }
[hidden] { display: none !important; } /* class display rules would otherwise win */
[hidden] { display: none !important; } /* class display rules would otherwise win */
html { scroll-behavior: smooth; scroll-padding-top: calc(76px + env(safe-area-inset-top)); }
/* On a computer the header and the Activity bar are the window's chrome: the page scrolls in #scroll
body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: calc(46px + env(safe-area-inset-bottom));
between them, so neither covers anything. Phones scroll the window (see the phone rules below). */
body { margin: 0; color: var(--text); height: 100vh; display: flex; flex-direction: column; overflow: hidden;
padding-bottom: calc(42px + env(safe-area-inset-bottom));
font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif;
font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif;
background: radial-gradient(1400px 700px at 15% -15%, #2a475e 0%, transparent 60%),
background: radial-gradient(1400px 700px at 15% -15%, #2a475e 0%, transparent 60%),
linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; }
linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; }
@@ -38,13 +40,14 @@
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-track { background: transparent; }
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; ga p: 30 px;
#scroll { flex: 1; min-height: 0; overflow: auto; scroll-behavior: smooth; scroll-padding-to p: 12 px; }
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px; flex: none; min-width: 0;
height: calc(64px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(28px + env(safe-area-inset-right)) 0 calc(28px + env(safe-area-inset-left)); background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px);
height: calc(64px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(28px + env(safe-area-inset-right)) 0 calc(28px + env(safe-area-inset-left)); background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px);
box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 6px 18px rgba(0,0,0,.35); }
box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 6px 18px rgba(0,0,0,.35); }
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; }
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; flex: none; }
.brand svg { width: 30px; height: 30px; }
.brand svg { width: 30px; height: 30px; }
.brand b { font-weight: 700; font-size: 15px; letter-spacing: 2.5px; }
.brand b { font-weight: 700; font-size: 15px; letter-spacing: 2.5px; }
nav { display: flex; gap: 4px; height: 100%; }
nav { display: flex; gap: 4px; height: 100%; flex: none; }
nav a { display: flex; align-items: center; padding: 0 13px; height: 100%; color: var(--text); text-decoration: none;
nav a { display: flex; align-items: center; padding: 0 13px; height: 100%; color: var(--text); text-decoration: none;
font-weight: 500; font-size: 15px; letter-spacing: 1.2px; text-transform: uppercase;
font-weight: 500; font-size: 15px; letter-spacing: 1.2px; text-transform: uppercase;
border-bottom: 3px solid transparent; transition: color .15s; }
border-bottom: 3px solid transparent; transition: color .15s; }
@@ -58,10 +61,17 @@
.dot.off { background: var(--bad); }
.dot.off { background: var(--bad); }
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; }
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; align-items: start; }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
/* The header gives way as the window narrows: the wordmark goes, then the tabs tighten, then the
@media (max-width: 820px) { .brand b { display: none; } }
connection pill drops its second line. It fits down to the app's 760 px minimum, with room for
the macOS window buttons the app adds on the left. */
header > button:not(.pill), header > .chip { flex: none; }
header > button:not(.pill):not(.chip) { width: 36px; padding: 0; justify-content: center; }
@media (max-width: 1250px) { .brand b { display: none; } }
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 14px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
@media (max-width: 960px) { header { gap: 10px; } nav { gap: 0; } nav a { padding: 0 8px; font-size: 13px; letter-spacing: .4px; }
.brand, #refreshAll { display: none; } .pill .pt span { display: none; } }
nav svg { display: none; }
nav svg { display: none; }
html { -webkit-text-size-adjust: 100%; }
html { -webkit-text-size-adjust: 100%; }
body { -webkit-tap-highlight-color: transparent; }
body { -webkit-tap-highlight-color: transparent; }
@@ -87,7 +97,7 @@
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
/* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */
/* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 4 20px; min-width: 190px ; flex: 0 1 auto; background: var(--btn); }
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 3 20px; min-width: 0 ; flex: 0 1 auto; background: var(--btn); }
.pill .dot { flex: none; }
.pill .dot { flex: none; }
.pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; }
.pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .35; } }
@keyframes pulse { 50% { opacity: .35; } }
@@ -149,8 +159,10 @@
/* ---- panels and section headers ---- */
/* ---- panels and section headers ---- */
.panel { background: linear-gradient(180deg, rgba(42,47,56,.72) 0%, rgba(30,35,41,.9) 100%);
.panel { background: linear-gradient(180deg, rgba(42,47,56,.72) 0%, rgba(30,35,41,.9) 100%);
border-radius: 4px; padding: 18px; box-shadow: 0 8px 24px rgba(0,0,0,.25); min-width: 0; }
border-radius: 4px; padding: 18px; box-shadow: 0 8px 24px rgba(0,0,0,.25); min-width: 0; }
.shelf-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; }
/* Buttons in a heading row wrap onto a line of their own rather than squeeze the heading. */
.shelf-head h2 { margin: 0; font-size: 13px; font-weight: 700 ; letter-spacing: 2px; text-transform: uppercase; color: var(--text) ; }
.shelf-head { display: flex; align-items: baseline ; f lex-wrap: wrap; gap: 8px 12px; margin: 0 0 14px ; }
.panel > .shelf-head:first-child h2 { line-height: 28px; } /* as tall as a button, so headings line up across panels */
.shelf-head h2 { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: var(--text); white-space: nowrap; }
.shelf-head .count { color: var(--muted); font-size: 13px; }
.shelf-head .count { color: var(--muted); font-size: 13px; }
.shelf-head .spacer { flex: 1; }
.shelf-head .spacer { flex: 1; }
.sub { color: var(--muted); font-size: 12.5px; }
.sub { color: var(--muted); font-size: 12.5px; }
@@ -179,6 +191,11 @@
border-radius: 3px; padding: 9px 11px; font: inherit; }
border-radius: 3px; padding: 9px 11px; font: inherit; }
textarea { resize: vertical; min-height: 76px; }
textarea { resize: vertical; min-height: 76px; }
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
/* Every drop-down dark, like the fields; the panels' own rules below still size them. */
select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 7px 8px; font: inherit; font-size: 13px; max-width: 100%; min-width: 0; }
select:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; }
option { background: #1e2329; color: var(--text); }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
/* ---- headset view ---- */
/* ---- headset view ---- */
@@ -223,15 +240,21 @@
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin { to { transform: rotate(360deg); } }
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
/* ---- device ---- */
/* ---- the headset's details: a menu under the battery chip ---- */
.battery { display: flex; gap: 16px; align-items: center; padding: 16px; border-radius: 3px; background: rgba(0,0,0,.22); }
.pop { position: fixed; inset: auto; margin: 0; width: min(390px, calc(100vw - 24px)); max-height: calc(100vh - 90px); overflow: auto;
.battery svg { width: 74px; height: 38px; flex: none; }
padding: 16px; border: 1px solid rgba(255,255,255,.1); border-radius: 6px; background: #1e2329; color: var(--text);
.battery .pct { font-size: 34px; font-weight: 300; color: var(--bright); line-height: 1; font-variant-numeric: tabular-nums ; }
box-shadow: 0 20px 60px rgba(0,0,0,.6); -webkit-app-region: no-drag ; }
.battery .state { font-weight: 700; font-size: 13px; letter-spacing: 1.2px; text-transform: uppercase; margin-top : 6 px; }
.pop .shelf-head { margin-bot tom : 10 px; }
.battery .state.charging { co lor : var(--green-hi); } .battery .state.full { color: var(--green) ; }
.pop .shelf-head h2 { min-width: 0; overf low : hidden; text-overflow: ellipsis ; }
.battery .state.low { color: var(--bad); } .battery .state.draining { color: var(--muted) ; }
.pop .stats { margin-top: 12px ; }
.battery .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; }
.pop .chips { margin-top: 1 2px; }
.batt .state { font-weight: 700; font-size: 12.5px; letter-spacing: 1.2px; text-transform: uppercase; }
.batt .state.charging { color: var(--green-hi); } .batt .state.full { color: var(--green); }
.batt .state.low { color: var(--bad); } .batt .state.draining { color: var(--text); }
.batt .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; }
button.chip { height: 32px; padding: 0 12px; font-size: 13px; }
.stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; margin-top: 14px; border-radius: 3px; overflow: hidden; }
.stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; margin-top: 14px; border-radius: 3px; overflow: hidden; }
.stats > .stat:last-child:nth-child(odd) { grid-column: 1 / -1; }
.stat { background: rgba(0,0,0,.18); padding: 10px 12px; min-width: 0; }
.stat { background: rgba(0,0,0,.18); padding: 10px 12px; min-width: 0; }
.stat .k { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; }
.stat .k { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; }
.stat .v { color: var(--bright); font-size: 17px; margin-top: 1px; font-variant-numeric: tabular-nums;
.stat .v { color: var(--bright); font-size: 17px; margin-top: 1px; font-variant-numeric: tabular-nums;
@@ -350,7 +373,7 @@
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
border-radius: 3px; padding: 8px 10px; font: inherit; }
border-radius: 3px; padding: 8px 10px; font: inherit; }
#titleForm select:focus { outline: none; border-color: var(--blue); }
#titleForm select:focus { border-color: var(--blue); }
#titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#titleForm .note.warn { color: #d9a23a; }
#titleForm .note.warn { color: #d9a23a; }
#titleList { margin-top: 8px; }
#titleList { margin-top: 8px; }
@@ -397,7 +420,6 @@
.game-card .bar > i { display: block; height: 100%; background: var(--action); }
.game-card .bar > i { display: block; height: 100%; background: var(--action); }
.f-0 { color: var(--muted); }
.f-0 { color: var(--muted); }
/* ---- Android display ---- */
/* ---- Android display ---- */
#mvQuality { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 4px 6px; font: inherit; font-size: 12px; }
.mv-windows { max-height: 420px; overflow-y: auto; }
.mv-windows { max-height: 420px; overflow-y: auto; }
.mv-perm { background: rgba(255,190,60,.08); border: 1px solid rgba(255,190,60,.25); border-radius: 4px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; }
.mv-perm { background: rgba(255,190,60,.08); border: 1px solid rgba(255,190,60,.25); border-radius: 4px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; }
.mv-perm .row { margin-top: 8px; }
.mv-perm .row { margin-top: 8px; }
@@ -406,7 +428,7 @@
.mv-opt .sub { display: block; margin-left: 22px; font-size: 12px; }
.mv-opt .sub { display: block; margin-left: 22px; font-size: 12px; }
.disp select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
.disp select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 8px 10px; font: inherit; font-size: 13px; }
padding: 8px 10px; font: inherit; font-size: 13px; }
.disp select:focus { outline: none; border-color: var(--blue); }
.disp select:focus { border-color: var(--blue); }
.disp .stats { grid-template-columns: repeat(3, 1fr); margin: 12px 0 4px; }
.disp .stats { grid-template-columns: repeat(3, 1fr); margin: 12px 0 4px; }
.disp select:disabled { opacity: .6; }
.disp select:disabled { opacity: .6; }
.disp .stat .v { font-size: 15px; }
.disp .stat .v { font-size: 15px; }
@@ -433,11 +455,14 @@
/* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of
/* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of
the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */
the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */
@media (max-width: 640px), (max-height: 500px) and (hover: none) {
@media (max-width: 640px), (max-height: 500px) and (hover: none) {
html { scroll-padding-top: calc(64px + env(safe-area-inset-top)); }
html { scroll-behavior: smooth; scroll-padding-top: calc(64px + env(safe-area-inset-top)); }
body { height: auto; display: block; overflow: visible; }
#scroll { overflow: visible; }
/* No blur here: it would make the header the containing block of the fixed tab bar. */
/* No blur here: it would make the header the containing block of the fixed tab bar. */
header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px;
header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px;
backdrop-filter: none; background: rgb(23, 29, 37); }
backdrop-filter: none; background: rgb(23, 29, 37); }
.brand svg { width: 26px; height: 26px; }
.brand svg { width: 26px; height: 26px; }
.brand { display: flex; } #refreshAll { display: inline-flex; } .pill .pt span { display: block; } /* the tabs are at the bottom here */
header .chip { height: 30px; padding: 0 10px; }
header .chip { height: 30px; padding: 0 10px; }
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
@@ -623,7 +648,7 @@
.detail-layout { display:flex; flex-direction:column; gap:24px; } .detail-buy { order:-1; width:100%; }
.detail-layout { display:flex; flex-direction:column; gap:24px; } .detail-buy { order:-1; width:100%; }
.sources-dialog { padding:24px; } .sources-dialog .store-close { margin:-12px -12px -20px 0; }
.sources-dialog { padding:24px; } .sources-dialog .store-close { margin:-12px -12px -20px 0; }
}
}
@media(prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } }
@media(prefers-reduced-motion:reduce) { html, #scroll { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } }
< / style >
< / style >
< / head >
< / head >
@@ -648,7 +673,7 @@
< div class = "spacer" > < / div >
< div class = "spacer" > < / div >
< button class = "pill" id = "conn" role = "status" aria-live = "polite" title = "Connection details" > < span class = "dot wait" > < / span > < span class = "pt" > < b > Connecting…< / b > < span > < / span > < / span > < / button >
< button class = "pill" id = "conn" role = "status" aria-live = "polite" title = "Connection details" > < span class = "dot wait" > < / span > < span class = "pt" > < b > Connecting…< / b > < span > < / span > < / span > < / button >
< select class = "devsel desk-only" id = "devSel" aria-label = "Headset" title = "Switch headset" hidden > < / select >
< select class = "devsel desk-only" id = "devSel" aria-label = "Headset" title = "Switch headset" hidden > < / select >
< span class = "chip" id = "battChip" title = "Battery" > —< / span >
< button class = "chip" id = "battChip" title = "Battery and headset details" popovertarget = "devPop" aria-haspopup = "dialog" hidden > —< / button >
< button id = "reportBtn" data-report title = "Report a problem, with diagnostics" aria-label = "Report a problem" >
< button id = "reportBtn" data-report title = "Report a problem, with diagnostics" aria-label = "Report a problem" >
< svg width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.2" aria-hidden = "true" > < path d = "M12 3l10 18H2z" / > < path d = "M12 10v5M12 18v.5" / > < / svg >
< svg width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.2" aria-hidden = "true" > < path d = "M12 3l10 18H2z" / > < path d = "M12 10v5M12 18v.5" / > < / svg >
< / button >
< / button >
@@ -656,7 +681,21 @@
< svg width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.4" aria-hidden = "true" > < path d = "M21 12a9 9 0 1 1-3-6.7" / > < path d = "M21 3v6h-6" / > < / svg >
< svg width = "15" height = "15" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2.4" aria-hidden = "true" > < path d = "M21 12a9 9 0 1 1-3-6.7" / > < path d = "M21 3v6h-6" / > < / svg >
< / button >
< / button >
< / header >
< / header >
< div class = "pop" id = "devPop" popover role = "dialog" aria-labelledby = "devPopTitle" >
< div class = "shelf-head" > < h2 id = "devPopTitle" > Headset< / h2 > < span class = "count" id = "devName" > < / span > < span class = "spacer" > < / span > < span class = "sub" id = "updated" > < / span > < / div >
< div class = "batt" > < div class = "state draining" id = "battState" > < / div > < div class = "detail" id = "battDetail" > < / div > < / div >
< div class = "stats" >
< div class = "stat" > < div class = "k" > Storage free< / div > < div class = "v" id = "sDisk" > —< / div > < div class = "meter" > < i id = "mDisk" > < / i > < / div > < / div >
< div class = "stat" > < div class = "k" > Memory used< / div > < div class = "v" id = "sMem" > —< / div > < div class = "meter" > < i id = "mMem" > < / i > < / div > < / div >
< div class = "stat" > < div class = "k" > Temperature< / div > < div class = "v" id = "sTemp" > —< / div > < div class = "d" id = "dTemp" > < / div > < / div >
< div class = "stat" > < div class = "k" > Wi-Fi< / div > < div class = "v" id = "sWifi" > —< / div > < div class = "d" id = "dWifi" > < / div > < / div >
< div class = "stat" > < div class = "k" > Uptime< / div > < div class = "v" id = "sUp" > —< / div > < div class = "d" id = "dUp" > < / div > < / div >
< div class = "stat" > < div class = "k" > SteamOS< / div > < div class = "v" id = "sOs" > —< / div > < div class = "d" id = "dOs" > < / div > < / div >
< / div >
< div class = "chips" id = "services" aria-label = "Running on the headset" > < / div >
< / div >
< div id = "scroll" >
< main >
< main >
< div class = "notice" id = "updateBar" hidden >
< div class = "notice" id = "updateBar" hidden >
< div class = "grow" id = "updateText" > < / div >
< div class = "grow" id = "updateText" > < / div >
@@ -747,29 +786,7 @@
< / section >
< / section >
< section class = "panel" id = "device" >
< section class = "panel" id = "device" >
< div class = "shelf-head" > < h2 > Device < / h2 > < span class = "count" id = "devName" > < / span > < span class = "spacer" > < / span > < span class = "sub" id = "updated" > < / span > < / div >
< div class = "shelf-head" > < h2 > Volume < / h2 > < / div >
< div class = "battery" >
< svg viewBox = "0 0 74 38" aria-hidden = "true" >
< rect x = "1.5" y = "1.5" width = "64" height = "35" rx = "5" fill = "none" stroke = "#8f98a0" stroke-width = "3" / >
< rect x = "67" y = "12" width = "5.5" height = "14" rx = "2" fill = "#8f98a0" / >
< rect id = "battFill" x = "6" y = "6" width = "0" height = "26" rx = "2.5" fill = "#59bf40" / >
< path id = "battBolt" d = "M37 5 L27 21 H34 L31 33 L42 16 H35 Z" fill = "#fff" stroke = "#1e2329" stroke-width = "1.6" stroke-linejoin = "round" visibility = "hidden" / >
< / svg >
< div style = "min-width:0" >
< div class = "pct" id = "battPct" > —< / div >
< div class = "state draining" id = "battState" > < / div >
< div class = "detail" id = "battDetail" > < / div >
< / div >
< / div >
< div class = "stats" >
< div class = "stat" > < div class = "k" > Storage free< / div > < div class = "v" id = "sDisk" > —< / div > < div class = "meter" > < i id = "mDisk" > < / i > < / div > < / div >
< div class = "stat" > < div class = "k" > Memory used< / div > < div class = "v" id = "sMem" > —< / div > < div class = "meter" > < i id = "mMem" > < / i > < / div > < / div >
< div class = "stat" > < div class = "k" > Temperature< / div > < div class = "v" id = "sTemp" > —< / div > < div class = "d" id = "dTemp" > < / div > < / div >
< div class = "stat" > < div class = "k" > Wi-Fi< / div > < div class = "v" id = "sWifi" > —< / div > < div class = "d" id = "dWifi" > < / div > < / div >
< div class = "stat" > < div class = "k" > Uptime< / div > < div class = "v" id = "sUp" > —< / div > < div class = "d" id = "dUp" > < / div > < / div >
< div class = "stat" > < div class = "k" > SteamOS< / div > < div class = "v" id = "sOs" > —< / div > < div class = "d" id = "dOs" > < / div > < / div >
< / div >
< div class = "chips" id = "services" > < / div >
< div class = "vol" >
< div class = "vol" >
< button class = "small" id = "muteBtn" aria-label = "Mute" >
< button class = "small" id = "muteBtn" aria-label = "Mute" >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "currentColor" aria-hidden = "true" > < path d = "M4 9h4l5-4v14l-5-4H4z" / > < path id = "volWaves" d = "M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12" fill = "none" stroke = "currentColor" stroke-width = "2" / > < / svg >
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "currentColor" aria-hidden = "true" > < path d = "M4 9h4l5-4v14l-5-4H4z" / > < path id = "volWaves" d = "M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12" fill = "none" stroke = "currentColor" stroke-width = "2" / > < / svg >
@@ -783,7 +800,7 @@
< section class = "panel" id = "vrTools" >
< section class = "panel" id = "vrTools" >
< div class = "shelf-head" > < h2 > VR comfort and performance< / h2 > < span class = "spacer" > < / span > < span class = "sub" id = "vrUpdated" > Waiting for the Frame< / span > < / div >
< div class = "shelf-head" > < h2 > VR comfort and performance< / h2 > < span class = "spacer" > < / span > < span class = "sub" id = "vrUpdated" > Waiting for the Frame< / span > < / div >
< div class = "stats" id = "vrMetrics" > < / div >
< div class = "stats" id = "vrMetrics" > < / div >
< div class = "hint" > Compositor FPS measures SteamVR output, not game FPS. Application FPS is unavailable when no app supplies timing. GPU time is render time, not GPU utilisation. T emperature is the ho ttest readable sensor .< / div >
< div class = "hint" > Compositor FPS measures SteamVR output, not game FPS. Application FPS is unavailable when no app supplies timing. GPU time is render time, not GPU utilisation. Battery and t emperature are in the ba ttery menu at the top .< / div >
< div class = "chips" >
< div class = "chips" >
< button class = "small" id = "hudStart" > Open HUD in headset< / button >
< button class = "small" id = "hudStart" > Open HUD in headset< / button >
< button class = "small" id = "hudStop" > Close HUD< / button >
< button class = "small" id = "hudStop" > Close HUD< / button >
@@ -1087,22 +1104,23 @@
< section class = "panel" id = "panels" >
< section class = "panel" id = "panels" >
< div class = "shelf-head" > < h2 > Panel switcher< / h2 > < span class = "spacer" > < / span >
< div class = "shelf-head" > < h2 > Panel switcher< / h2 > < span class = "spacer" > < / span >
< button class = "small" id = "panelsRefresh" > Refresh< / button >
< button class = "small" id = "panelsRefresh" > Refresh< / button > < / div >
< button class = "small action" id = "panelsOpen" > Open in headset< / button > < / div >
< div class = "list" id = "panelList" > < div class = "sub" > Refresh to see open panels.< / div > < / div >
< div class = "list" id = "panelList" > < div class = "sub" > Refresh to see open panels.< / div > < / div >
< div class = "row" style = "margin-top:12px" > < button class = "small action" id = "panelsOpen" > Open in headset< / button > < / div >
< div class = "hint" > Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard.
< div class = "hint" > Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard.
Saved spatial layouts are not available yet.< / div >
Saved spatial layouts are not available yet.< / div >
< / section >
< / section >
< section class = "panel" id = "macview" hidden >
< section class = "panel" id = "macview" hidden >
< div class = "shelf-head" > < h2 > Mac in the headset< / h2 > < span class = "count" id = "mvCount" > < / span > < span class = "spacer" > < / span >
< div class = "shelf-head" > < h2 > Mac in the headset< / h2 > < span class = "count" id = "mvCount" > < / span > < span class = "spacer" > < / span >
< select id = "mvQuality" title = "Picture quality and bandwidth" >
< option value = "sharp" > Sharp< / option > < option value = "balanced" selected > Balanced< / option >
< option value = "light" > Light< / option > < option value = "compatible" > Compatible (JPEG)< / option >
< / select >
< button class = "small" id = "mvRefresh" > Refresh< / button > < / div >
< button class = "small" id = "mvRefresh" > Refresh< / button > < / div >
< label class = "mv-opt" > < input type = "checkbox" id = "mvSeparate" checked > Give each window its own display
< label class = "mv-opt" > < input type = "checkbox" id = "mvSeparate" checked > Give each window its own display
< span class = "sub" > its menus and sheets come along, nothing covers it, and it goes back when you stop< / span > < / label >
< span class = "sub" > its menus and sheets come along, nothing covers it, and it goes back when you stop< / span > < / label >
< div class = "row" style = "margin:0 0 12px" > < label for = "mvQuality" > Picture quality< / label >
< select id = "mvQuality" title = "Sharper pictures use more of your network" >
< option value = "sharp" > Sharp< / option > < option value = "balanced" selected > Balanced< / option >
< option value = "light" > Light< / option > < option value = "compatible" > Compatible (JPEG)< / option >
< / select > < / div >
< div class = "mv-perm" id = "mvPerm" hidden > < / div >
< div class = "mv-perm" id = "mvPerm" hidden > < / div >
< div class = "list" id = "mvDisplays" > < / div >
< div class = "list" id = "mvDisplays" > < / div >
< div class = "shelf-head" style = "margin-top:16px" > < h2 > Windows< / h2 > < span class = "count" id = "mvWinCount" > < / span > < / div >
< div class = "shelf-head" style = "margin-top:16px" > < h2 > Windows< / h2 > < span class = "count" id = "mvWinCount" > < / span > < / div >
@@ -1154,6 +1172,7 @@
< / div >
< / div >
< / div >
< / div >
< / main >
< / main >
< / div >
< div class = "dropzone" id = "dropzone" hidden > < div > < b > Drop to send to the Frame< / b >
< div class = "dropzone" id = "dropzone" hidden > < div > < b > Drop to send to the Frame< / b >
< span class = "sub" > Files go to < code > ~/Downloads< / code > ; < code > .apk< / code > files install as Android apps;
< span class = "sub" > Files go to < code > ~/Downloads< / code > ; < code > .apk< / code > files install as Android apps;
a game's < code > .zip< / code > , folder or < code > .exe< / code > becomes a Steam library title.< / span > < / div > < / div >
a game's < code > .zip< / code > , folder or < code > .exe< / code > becomes a Steam library title.< / span > < / div > < / div >
@@ -1344,6 +1363,9 @@ function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&",
function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; }
function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; }
function dur(s) { const d = Math.floor(s/86400), h = Math.floor(s%86400/3600), m = Math.floor(s%3600/60);
function dur(s) { const d = Math.floor(s/86400), h = Math.floor(s%86400/3600), m = Math.floor(s%3600/60);
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`; }
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`; }
// What scrolls: #scroll on a computer, where the header and Activity bar stay put; the window on phones.
function scroller() { return getComputedStyle($("scroll")).overflowY === "visible" ? document.scrollingElement : $("scroll"); }
function scrollToY(y) { scroller().scrollTo({ top: y, behavior: "instant" }); }
function meter(el, pct, warnAt, badAt) {
function meter(el, pct, warnAt, badAt) {
el.style.width = Math.max(0, Math.min(100, pct)) + "%";
el.style.width = Math.max(0, Math.min(100, pct)) + "%";
el.className = pct >= badAt ? "bad" : pct >= warnAt ? "warn" : "";
el.className = pct >= badAt ? "bad" : pct >= warnAt ? "warn" : "";
@@ -1464,7 +1486,7 @@ function setOnline(ok, why) {
if (!ok) {
if (!ok) {
$("offMsg").textContent = why || "Can't reach the Frame";
$("offMsg").textContent = why || "Can't reach the Frame";
setConn(false, "Offline");
setConn(false, "Offline");
$("battChip").hidden = true;
$("battChip").hidden = true; closeDevPop();
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
if (window.connBanner) window.connBanner();
if (window.connBanner) window.connBanner();
} else if (was === false) {
} else if (was === false) {
@@ -1512,7 +1534,7 @@ function renderPerformance(s) {
const values = [["Compositor FPS", p.compositorFps, ""], ["Compositor period", p.frameMs, " ms"],
const values = [["Compositor FPS", p.compositorFps, ""], ["Compositor period", p.frameMs, " ms"],
["Application FPS", p.appFps, ""], ["Render GPU time", p.gpuMs, " ms"],
["Application FPS", p.appFps, ""], ["Render GPU time", p.gpuMs, " ms"],
["Compositor CPU", p.compositorCpuMs, " ms"], ["System CPU", p.cpuPercent, "%"],
["Compositor CPU", p.compositorCpuMs, " ms"], ["System CPU", p.cpuPercent, "%"],
["GPU clock", p.gpuMHz, " MHz"], ["Hottest sensor", s?.temp, "°C"], ["Battery", s?.battery?.percent, "%"] ];
["GPU clock", p.gpuMHz, " MHz"]];
$("vrMetrics").innerHTML = values.map(([label, v, unit]) => `< div class = "stat" > < div class = "k" > ${label}< / div > < div class = "v" > ${Number.isFinite(v) ? v.toFixed(1) + unit : "Unavailable"}< / div > < / div > `).join("");
$("vrMetrics").innerHTML = values.map(([label, v, unit]) => `< div class = "stat" > < div class = "k" > ${label}< / div > < div class = "v" > ${Number.isFinite(v) ? v.toFixed(1) + unit : "Unavailable"}< / div > < / div > `).join("");
$("vrUpdated").textContent = s ? `Sampled ${new Date(s.time * 1000).toLocaleTimeString()} · refreshes with status` : "No live sample";
$("vrUpdated").textContent = s ? `Sampled ${new Date(s.time * 1000).toLocaleTimeString()} · refreshes with status` : "No live sample";
}
}
@@ -1644,10 +1666,6 @@ function battery(b, power) {
const draining = !full & & !charging & & !idle & & (!power || b?.status === "Discharging" || b?.watts < 0 ) ;
const draining = !full & & !charging & & !idle & & (!power || b?.status === "Discharging" || b?.watts < 0 ) ;
const low = !charging & & !full & & pct != null & & pct < = 15;
const low = !charging & & !full & & pct != null & & pct < = 15;
const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null & & pct < = 30 ? "#d9a23a" : "#dcdedf";
const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null & & pct < = 30 ? "#d9a23a" : "#dcdedf";
$("battFill").setAttribute("width", pct == null ? 0 : (55 * Math.min(100, Math.max(0, pct)) / 100).toFixed(1));
$("battFill").setAttribute("fill", color);
$("battBolt").setAttribute("visibility", charging ? "visible" : "hidden");
$("battPct").textContent = pct == null ? "—" : `${pct}%`;
let label, cls, detail = [];
let label, cls, detail = [];
if (!b) { label = "Battery unknown"; cls = "draining"; }
if (!b) { label = "Battery unknown"; cls = "draining"; }
@@ -1663,7 +1681,7 @@ function battery(b, power) {
if (b?.timeToEmpty > 0 & & b.timeToEmpty < 86400 ) detail . push ( ` About $ { dur ( b . timeToEmpty ) } left ` ) ;
if (b?.timeToEmpty > 0 & & b.timeToEmpty < 86400 ) detail . push ( ` About $ { dur ( b . timeToEmpty ) } left ` ) ;
}
}
if (power) detail.push(`${/PD/.test(power.type || "") ? "USB-C PD" : power.type || "Charger"}${power.watts ? ` ${Math.round(power.watts)} W` : ""}`);
if (power) detail.push(`${/PD/.test(power.type || "") ? "USB-C PD" : power.type || "Charger"}${power.watts ? ` ${Math.round(power.watts)} W` : ""}`);
if (b?.tempC != null) detail.push(`${b.tempC.toFixed(0)}°C`);
if (b?.tempC != null) detail.push(`battery ${b.tempC.toFixed(0)}°C`);
$("battState").textContent = label;
$("battState").textContent = label;
$("battState").className = "state " + cls;
$("battState").className = "state " + cls;
$("battDetail").textContent = detail.join(" · ") || " ";
$("battDetail").textContent = detail.join(" · ") || " ";
@@ -1672,13 +1690,25 @@ function battery(b, power) {
const chipPct = pct == null ? 0 : Math.min(100, Math.max(0, pct));
const chipPct = pct == null ? 0 : Math.min(100, Math.max(0, pct));
$("battChip").innerHTML = pct == null ? "—" :
$("battChip").innerHTML = pct == null ? "—" :
`< svg width = "22" height = "12" viewBox = "0 0 22 12" aria-hidden = "true" > < rect x = ".75" y = ".75" width = "18" height = "10.5" rx = "2" fill = "none" stroke = "#8f98a0" stroke-width = "1.5" / > < rect x = "19.8" y = "3.5" width = "1.8" height = "5" rx = ".6" fill = "#8f98a0" / > < rect x = "2.5" y = "2.5" width = "${(14.5 * chipPct / 100).toFixed(1)}" height = "7" rx = "1" fill = "${color}" / > < / svg > ${bolt}< span > ${pct}%< / span > `;
`< svg width = "22" height = "12" viewBox = "0 0 22 12" aria-hidden = "true" > < rect x = ".75" y = ".75" width = "18" height = "10.5" rx = "2" fill = "none" stroke = "#8f98a0" stroke-width = "1.5" / > < rect x = "19.8" y = "3.5" width = "1.8" height = "5" rx = ".6" fill = "#8f98a0" / > < rect x = "2.5" y = "2.5" width = "${(14.5 * chipPct / 100).toFixed(1)}" height = "7" rx = "1" fill = "${color}" / > < / svg > ${bolt}< span > ${pct}%< / span > `;
$("battChip").title = label;
$("battChip").title = `${ label}. Click for the headset's details.` ;
$("battChip").hidden = pct == null ;
$("battChip").setAttribute("aria-label", pct == null ? "Headset details" : `Battery ${pct}%, ${label}. Headset details`) ;
$("battChip").hidden = false; // even without a battery reading: it's the way to the headset's details
}
}
// hidePopover throws where the menu isn't open (and doesn't exist in older browsers).
function closeDevPop() { try { $("devPop").hidePopover(); } catch {} }
// The battery chip's menu opens under the chip, right-aligned with it.
$("devPop").addEventListener("beforetoggle", e => {
if (e.newState !== "open") return;
const r = $("battChip").getBoundingClientRect();
$("devPop").style.top = `${Math.round(r.bottom + 8)}px`;
const width = Math.min(390, innerWidth - 24); // as .pop's CSS width
$("devPop").style.right = `${Math.max(12, Math.min(Math.round(innerWidth - r.right), innerWidth - width - 12))}px`;
});
function render(s) {
function render(s) {
renderPerformance(s);
renderPerformance(s);
battery(s.battery, s.power);
battery(s.battery, s.power);
$("devPopTitle").textContent = $("devPopTitle").title = window.connName || "Headset";
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
const h = s.disk.home;
const h = s.disk.home;
@@ -2695,7 +2725,7 @@ function showApkAlternatives(reason, result, dev = window.connDevice) {
}, dev));
}, dev));
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
await loadAndroid();
await loadAndroid();
if (cat.apps) { const y = window .scrollY ; filterCatalog(); window. scrollTo(0, y); }
if (cat.apps) { const y = scroller() .scrollTop ; filterCatalog(); scrollToY (y); }
if (res & & res.app) await offerTest(res.app);
if (res & & res.app) await offerTest(res.app);
};
};
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
@@ -2929,7 +2959,7 @@ function renderChips() {
}
}
function filterGames(keepScroll) {
function filterGames(keepScroll) {
const y = window .scrollY ;
const y = scroller() .scrollTop ;
if (gm.src === "owned") {
if (gm.src === "owned") {
if (!gm.owned) return;
if (!gm.owned) return;
const terms = gm.q.toLowerCase().split(/\s+/).filter(Boolean);
const terms = gm.q.toLowerCase().split(/\s+/).filter(Boolean);
@@ -2945,7 +2975,7 @@ function filterGames(keepScroll) {
$("gmGrid").innerHTML = ""; gm.shown = 0; moreGames(n);
$("gmGrid").innerHTML = ""; gm.shown = 0; moreGames(n);
if (!gm.results.length) $("gmGrid").innerHTML = `< div class = "sub" > ${gm.src === "store" & & !gm.storeQ
if (!gm.results.length) $("gmGrid").innerHTML = `< div class = "sub" > ${gm.src === "store" & & !gm.storeQ
? "Type a name and press Enter to search the Steam store." : "Nothing matches. Try turning on more ratings above."}< / div > `;
? "Type a name and press Enter to search the Steam store." : "Nothing matches. Try turning on more ratings above."}< / div > `;
if (keepScroll) window. scrollTo(0, y);
if (keepScroll) scrollToY (y);
}
}
function price(p) {
function price(p) {
@@ -3300,7 +3330,7 @@ document.body.addEventListener("click", async e => {
if (action === "probe") { loadCatalog(); loadReports(); }
if (action === "probe") { loadCatalog(); loadReports(); }
}
}
loadAndroid();
loadAndroid();
if (cat.apps) { const y = window .scrollY ; filterCatalog(); window. scrollTo(0, y); }
if (cat.apps) { const y = scroller() .scrollTop ; filterCatalog(); scrollToY (y); }
});
});
loadCatalog();
loadCatalog();
@@ -3695,7 +3725,7 @@ function showPage() {
document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page));
document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page));
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
const section = !PAGES.includes(id) & & id & & $(id);
const section = !PAGES.includes(id) & & id & & $(id);
if (section) section.scrollIntoView(); else window. scrollTo(0, 0);
if (section) section.scrollIntoView(); else scrollToY (0);
if (page === "tools") { loadMacView(); loadPanels(); }
if (page === "tools") { loadMacView(); loadPanels(); }
}
}
window.addEventListener("hashchange", showPage);
window.addEventListener("hashchange", showPage);
@@ -4286,6 +4316,7 @@ function onConnection(s) {
renderPill(); renderConnDlg();
renderPill(); renderConnDlg();
const devId = s.device & & s.device.id;
const devId = s.device & & s.device.id;
window.connDevice = devId || null;
window.connDevice = devId || null;
window.connName = s.device ? s.device.name : null;
if (link.device !== null & & devId !== link.device) {
if (link.device !== null & & devId !== link.device) {
log(`Now using ${s.device.name}`, "ok");
log(`Now using ${s.device.name}`, "ok");
state = null;
state = null;
@@ -4315,7 +4346,7 @@ function onConnection(s) {
if ($("wiDlg").open & & !wi.job & & !wi.starting) $("wiDlg").close();
if ($("wiDlg").open & & !wi.job & & !wi.starting) $("wiDlg").close();
$("dispSel").innerHTML = "< option > Loading…< / option > "; $("dispSel").disabled = true; $("dispCtl").hidden = true;
$("dispSel").innerHTML = "< option > Loading…< / option > "; $("dispSel").disabled = true; $("dispCtl").hidden = true;
link.reload = true; // everything on the page was the other headset's
link.reload = true; // everything on the page was the other headset's
$("battChip").hidden = true;
$("battChip").hidden = true; closeDevPop();
}
}
if (s.phase !== link.phase || devId !== link.device) {
if (s.phase !== link.phase || devId !== link.device) {
if (s.phase === "connected") {
if (s.phase === "connected") {