Compare commits

..
Author SHA1 Message Date
saphidandClaude Opus 5.5 1863c92101 Show the focus ring on the title form's and display picker's selects too
The fix review found two older rules (#titleForm select:focus, .disp select:focus)
that outranked the new select:focus-visible outline and still removed it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:32:28 +10:00
saphidandClaude Opus 5.5 c0ae75d56b Keep the headset menu reachable and on screen, and keep focus visible
From the independent review of this PR:

- Without a battery reading the chip was hidden, and with it the only way to
  the headset's details. It now stays, labelled Headset details.
- On a 375 px phone the menu lined up with the chip and ran 82 px off the
  left edge. It now stays 12 px inside the window.
- A long headset name pushed the menu wider; it's now cut short with an
  ellipsis (the full name is in the tooltip).
- Reduced motion turned off smooth scrolling for the page but not for the
  new scroll area.
- Selects lost their focus ring; keyboard focus now shows a blue outline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 20:56:32 +10:00
saphidandClaude Opus 5.5 12aaec7888 Keep tool headings on one line and label the battery temperature
Mac in the headset's picture quality moves into the panel with a label, and the
panel switcher's Open in headset button sits under its list, so neither heading
wraps in a narrow column. Headings line up across panels whether or not they
hold a button. An odd last figure in a stats grid spans the row instead of
leaving a hole. The battery menu says which temperature is the battery's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:31:02 +10:00
saphidandClaude Opus 5.5 32fbbfe475 Fit the window at every size and show the battery once
The page now scrolls between the header and the Activity bar on a computer,
so the bar never covers content, and nothing overflows sideways from the
760 x 560 minimum up. The header gives way as the window narrows: the
wordmark goes, the tabs tighten, and below 960 px the logo, Refresh (R still
works) and the pill's second line go. Phones keep scrolling the window.

The battery shows once, in the header chip. Clicking it opens a menu with
the headset's storage, memory, temperature, Wi-Fi, uptime, SteamOS build and
services; the big battery card is gone from Home, and the VR performance
table no longer repeats battery and temperature.

Heading rows wrap their buttons onto a new line instead of squeezing the
heading, and every drop-down is dark.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:19:19 +10:00
6 changed files with 115 additions and 125 deletions

No files matched your search

+2 -11
View File
@@ -5,11 +5,6 @@ on:
branches: [main] branches: [main]
pull_request: pull_request:
# A newer push to the same pull request supersedes a run still in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs: jobs:
checks: checks:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -22,9 +17,7 @@ jobs:
with: with:
node-version: "24" node-version: "24"
- name: Install zsh - name: Install zsh
# The runner image's third-party apt sources (packages.microsoft.com) sometimes run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
- name: Script syntax - name: Script syntax
run: | run: |
sh -n ui/local-bin/ssh sh -n ui/local-bin/ssh
@@ -91,8 +84,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install zsh - name: Install zsh
# The runner image's third-party apt sources (packages.microsoft.com) sometimes run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
- name: End-to-end tests - name: End-to-end tests
run: scripts/e2e.sh run: scripts/e2e.sh
+2 -12
View File
@@ -2,22 +2,14 @@ name: release
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches # Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
# the installers to that tag's GitHub release (created as a draft if missing). # the installers to that tag's GitHub release (created as a draft if missing).
# Pull requests that change how the installers are built (the Electron app, the # Pull requests that touch the app build the same installers as artifacts.
# macOS helper it compiles, or this workflow) build the same installers as
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
# are tested by the checks workflow; run this one by hand to package them early.
on: on:
push: push:
tags: ["v*"] tags: ["v*"]
pull_request: pull_request:
paths: ["app/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"] paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
workflow_dispatch: workflow_dispatch:
# A newer push to the same pull request supersedes a build still in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions: permissions:
contents: write contents: write
@@ -57,9 +49,7 @@ jobs:
tag="${GITHUB_REF_NAME}" tag="${GITHUB_REF_NAME}"
gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes "" gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes ""
gh release upload "$tag" ${{ matrix.files }} --clobber gh release upload "$tag" ${{ matrix.files }} --clobber
# Tagged builds are attached to the release above; only other builds need artifacts.
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
with: with:
name: frame-control-${{ matrix.os }} name: frame-control-${{ matrix.os }}
path: | path: |
+1 -1
View File
@@ -26,7 +26,7 @@ it twice reuses the existing process.
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. | | Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. | | System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. | | GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. | | Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. In the headset HUD only: the app shows them once, in the battery menu at the top. |
OpenVR uses background application mode, which does not start SteamVR or keep OpenVR uses background application mode, which does not start SteamVR or keep
it running. This mode also returned live timing in a read-only device probe. it running. This mode also returned live timing in a read-only device probe.
+1 -2
View File
@@ -23,8 +23,7 @@ started=$SECONDS
print "==> Building fakeframe-frame (from $base) and fakeframe-host" print "==> Building fakeframe-frame (from $base) and fakeframe-host"
# Quiet when it works; if a build fails, build again with the full log so CI shows why. # Quiet when it works; if a build fails, build again with the full log so CI shows why.
# That second build also rides out a flaky package mirror: only its failure is fatal. build() { docker build -q "$@" >/dev/null || { docker build --progress=plain "$@"; exit 2 } }
build() { docker build -q "$@" >/dev/null || docker build --progress=plain "$@" || exit 2 }
build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe
build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe
+8 -29
View File
@@ -120,27 +120,7 @@ class LauncherTests(unittest.TestCase):
FIXTURES = ROOT / 'tests/fixtures/library' FIXTURES = ROOT / 'tests/fixtures/library'
def wait_for_idle_resolvers(timeout=10):
# Name lookups that outlast their deadline give their slot back from their own thread,
# which a busy runner may not schedule before the next test; wait until all are back.
from apk_sources import _images
held = []
try:
end = time.monotonic() + timeout
while _images._resolvers.acquire(timeout=max(0, end - time.monotonic())):
held.append(1)
if len(held) == 4:
return
raise AssertionError('artwork name lookups from an earlier test are still running')
finally:
for _ in held:
_images._resolvers.release()
class ArtworkTests(unittest.TestCase): class ArtworkTests(unittest.TestCase):
def setUp(self):
wait_for_idle_resolvers()
def test_source_inputs_and_url(self): def test_source_inputs_and_url(self):
from apk_sources import _images from apk_sources import _images
data = (FIXTURES / 'icon.png').read_bytes() data = (FIXTURES / 'icon.png').read_bytes()
@@ -251,7 +231,10 @@ class ArtworkTests(unittest.TestCase):
self.assertEqual(sum('Too many' in e for e in errors), 2) self.assertEqual(sum('Too many' in e for e in errors), 2)
finally: finally:
gate.set() gate.set()
wait_for_idle_resolvers() # the stuck lookups finished and gave their slots back deadline = time.monotonic() + 5
while time.monotonic() < deadline and not _images._resolvers.acquire(blocking=False):
time.sleep(0.01)
_images._resolvers.release() # the stuck lookups finished and gave their slots back
def test_resolver_slot_released_when_thread_cannot_start(self): def test_resolver_slot_released_when_thread_cannot_start(self):
from apk_sources import _images from apk_sources import _images
@@ -259,14 +242,10 @@ class ArtworkTests(unittest.TestCase):
for _ in range(6): for _ in range(6):
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
_images.get('https://example.org/a.png', deadline=time.monotonic() + 1) _images.get('https://example.org/a.png', deadline=time.monotonic() + 1)
held = 0 for _ in range(4): # every slot came back
try: self.assertTrue(_images._resolvers.acquire(blocking=False))
while held < 4 and _images._resolvers.acquire(blocking=False): for _ in range(4):
held += 1 _images._resolvers.release()
self.assertEqual(held, 4) # every slot came back
finally:
for _ in range(held): # even on failure, so later tests don't inherit the leak
_images._resolvers.release()
def test_deadline_covers_name_resolution(self): def test_deadline_covers_name_resolution(self):
import threading import threading
+101 -70
View File
@@ -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; gap: 30px; #scroll { flex: 1; min-height: 0; overflow: auto; scroll-behavior: smooth; scroll-padding-top: 12px; }
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: 420px; min-width: 190px; flex: 0 1 auto; background: var(--btn); } .pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 320px; 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; flex-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: 6px; } .pop .shelf-head { margin-bottom: 10px; }
.battery .state.charging { color: var(--green-hi); } .battery .state.full { color: var(--green); } .pop .shelf-head h2 { min-width: 0; overflow: 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: 12px; }
.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">&nbsp;</div><div class="detail" id="battDetail">&nbsp;</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">&nbsp;</div></div>
<div class="stat"><div class="k">Wi-Fi</div><div class="v" id="sWifi">—</div><div class="d" id="dWifi">&nbsp;</div></div>
<div class="stat"><div class="k">Uptime</div><div class="v" id="sUp">—</div><div class="d" id="dUp">&nbsp;</div></div>
<div class="stat"><div class="k">SteamOS</div><div class="v" id="sOs">—</div><div class="d" id="dOs">&nbsp;</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">&nbsp;</div>
<div class="detail" id="battDetail">&nbsp;</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">&nbsp;</div></div>
<div class="stat"><div class="k">Wi-Fi</div><div class="v" id="sWifi">—</div><div class="d" id="dWifi">&nbsp;</div></div>
<div class="stat"><div class="k">Uptime</div><div class="v" id="sUp">—</div><div class="d" id="dUp">&nbsp;</div></div>
<div class="stat"><div class="k">SteamOS</div><div class="v" id="sOs">—</div><div class="d" id="dOs">&nbsp;</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. Temperature is the hottest 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 temperature are in the battery 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 => ({"&":"&amp;",
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") {