Compare commits

...
10 Commits
Author SHA1 Message Date
spoopyghosty0 ea2522a45d Merge pull request #225 from spoopyghosty0/showcase/update
Update docs screenshots and videos
2026-10-12 16:56:14 -04:00
spoopyghosty0 fa67b36d20 Docs: update screenshots and videos 2026-10-12 20:33:12 +00:00
spoopyghosty0 972f5c7147 Showcase: the install video hovers 'Quest games' (the Frame page's readiness rows lost '(Lepton)' in c477070) 2026-10-12 16:16:55 -04:00
spoopyghosty0 c477070772 GUI: plainer words, readable dim text, quieter empty tabs, one way to connect, portal switches
- Plain words: patch titles lose bracketed internals unless Show technical details is on (C.patch_title); a passed launch test just says so, how far it got shows only when it fails (C.test_line); Frame page readiness says Quest games / PC VR games and storage drops the file system (reason in a tooltip); the failure pop-up says where it stopped, that Resume continues and that saves and signing keys are kept. Engine and VR API stay on the game page; the OpenXR runtime and game launch capacity checks stay; the first start still names its tools.
- Readable dim text: TEXT_3 is #868A96 (Portal, Portal OLED) and #868B9C (Original), at least 4.5:1 on the window, sidebar, cards and raised panels (was 3.6-4.4:1); a test keeps the built-in themes there.
- Quieter empty tabs: the not-connected Files, Screenshots, Live view, Type on Frame and Monitor tabs show one sentence and Connect above a faint sketch of the tab (C.ghost_preview) instead of three feature cards; the Live view shows one Start button and no feature cards.
- The connect page has one way in: "Set up your Frame" up front (no password, nothing changes until you allow it), "Already set up?" below, the address and password behind "Connect by address…"; no 1/2/3 numbers.
- Consistency: every switch is C.switch, with the portal gradient painted under a transparent track (the real Switch keeps keyboard, focus and semantics), a dark thumb with a check when on and a grey one with a cross when off (the same size); where a patch came from is blue information, not orange; the platform filter says "Quest/Android"; the welcome screen says "Set up your Frame".
- Showcase: the live-view shot waits for "Ready when you are"; the install video clicks "Set up your Frame".
2026-10-12 15:46:14 -04:00
spoopyghosty0 2c1b901728 Site: less template, more FramePort
- Section labels name what you do there ("See how it works", "See what runs", "Get started") instead of 01-07; plain headlines ("What happens when you click Install on Frame", "FramePort patches the games that don't run as they are"); the hero's eyebrow is gone; nav "Install" -> "Get started" (no clash with the /install/ link page)
- Phones and narrow windows (below 1120 px) get a Menu panel instead of a hidden or wrapping nav; on phones the download buttons say "Get it on your PC"; no three.js on data saver or 2G/3G; the install log drops its detail column under 600 px
- The download page says why Windows and macOS warn on first start (self-signed) and where the SHA-256 sums are; Get started step 3 says nothing changes on the Frame until you click Allow, with a link to the script
- The app tour's screenshots and videos sit behind one disclosure; the game-maker panel shows the demo button on its own stage with the HTML behind "Show the HTML"; Compare without icon tiles, Free and open source stays the emphasized card
- Quieter motion: no cursor light, pointer-lit heading gradient or film grain; sections settle in with a 10 px rise, heading words rise without the tilt, labels decode once, a faint click ripple, a gentler hero lean, a lighter spring (--ease-spring); the warp field, the contributor dock and the gradient changelog version stay
- Readability: dim text #868a96 (4.77:1+), themed placeholders, pending install-log stages stay readable, no labels under 11px, the walkthrough shows each platform's real file and program (was "?os=linux" and FramePort.exe)
- Impeccable design-tool files (PRODUCT.md, DESIGN.md, .impeccable/) are ignored: they stay local
2026-10-12 12:57:12 -04:00
spoopyghosty0 80ef9ca6a7 Site: release and contributor data refreshed (1.0.4) 2026-10-11 21:30:25 -04:00
spoopyghosty0 c744f8740b Site: the games board lists games in a random order on every visit (working games first, then with issues, then not running) 2026-10-11 21:30:13 -04:00
spoopyghosty0 859f921361 Monitor: games Steam runs that FramePort didn't install count (e.g. Job Simulator from Steam's store, named from its appmanifest; End game = Steam's Exit game), overlay apps are never the game being played, and the newest game comes first (agent v81) 2026-10-11 17:27:10 -04:00
spoopyghosty0 6acf9d048e App icon: all four corners rounded (only the top-left one was); source runs on Windows show FramePort's icon instead of Flet's (window.icon from an .ico made of it) 2026-10-11 14:40:32 -04:00
spoopyghosty0 cf18b30be9 Uninstall dialog: the delete-saves checkbox label wraps (it ran past the dialog; also the delete-files-on-this-PC, share-recipe and remove-FramePort checkboxes), games uninstalled from the Frame page show their name; Monitor: the agent's own workers aren't a running game (uninstalling showed the game as now playing while Steam restarted; agent v80) 2026-10-11 14:33:47 -04:00
62 changed files with 810 additions and 416 deletions

No files matched your search

+5
View File
@@ -30,3 +30,8 @@ site/src/assets/media/
site/media-hq/
site/public/s
site/public/setup.sh
# Impeccable design-tool files (kept local, not in the repo)
/PRODUCT.md
/DESIGN.md
/.impeccable/
+4
View File
@@ -69,6 +69,10 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
Live Frame data comes from one app-owned `app.monitor_hub` (`frame/monitor_hub.py`): subscribers name modules +
interval, the hub runs one agent `_monitor` stream (union of modules, fastest interval; per-module collection
needs agent v73), reconnects after a lost stream and stops it when nobody subscribes; `_poll` retries an "error".
Games in a sample (agent v81): FramePort's own (launch.sh / container / Proton folder), any other game Steam's
reaper runs (`steam:<appid>`, title from the appmanifest, e.g. Job Simulator from Steam's store; not SteamVR, not
devkit entries; End game = Steam's Exit game), overlay apps flagged `overlay` and listed last; newest first. The
UI takes `frame/monitor.main_game(sample)` (never an overlay app alone). The agent's own workers never count.
Subscribers: "monitor" (the Monitor tab, everything, only while shown) and "card" = the sidebar's live Frame card
(`ui/frame_card.py` helpers: battery ring, "now playing" row with fps + 2-min sparkline → click opens Monitor;
games + battery every 5 s, paused while a job's stage is "Upload…", only while connected and setting
+52 -5
View File
@@ -44,7 +44,7 @@ try:
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
fcntl = None
AGENT_VERSION = 79
AGENT_VERSION = 81
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -6604,6 +6604,10 @@ MON_CRITICAL = ("steam", "steamwebhelper", "reaper", "vrserver", "vrcompositor",
# processes whose command line can name a game (only these are read: cmdlines of ~500 processes cost too much)
MON_ROOT_COMMS = ("conmon", "launch.sh", "reaper", "bash", "sh")
MON_ROOT_PREFIXES = ("python", "proton", "pv-", "wine", "steam-runtime")
AGENT_FILES = {os.path.abspath(__file__), os.path.realpath(__file__)}
STEAM_GAME = "steam:" # monitor: a game Steam started that FramePort didn't install ("steam:<appid>", e.g. a Lepton
# game from Steam's store); its title comes from the appmanifest
REAPER_APPID = re.compile(r"\bAppId=(\d+)")
MON_GROUPS = ( # (group, comm prefixes), first match wins; games are found by their process tree
("steamvr", ("vr", "XRService", "V4L2Cam", "eyetracking", "proxmicmute", "systemlayer")),
("steam", ("steam", "reaper", "fossilize")),
@@ -6935,6 +6939,7 @@ class Monitor:
self.logs = {} # launch.log path -> [offset, fps, avg ms, time of the line]
self.deps = []
self.deps_at = -1e9
self.steam_titles = {}
self.tick = 0
self.last = None # time of the previous sample
self.disk_at = -1e9
@@ -7035,10 +7040,16 @@ class Monitor:
m = re.search(r"lepton-steamlaunch-(\d+)", cmd)
if m and m.group(1) in by_appid:
root = (by_appid[m.group(1)]["package"], "container")
else:
elif not any(a in cmd for a in AGENT_FILES):
# the agent's own workers name a game's launch.sh too (uninstall's shortcut removal while Steam
# restarts): they aren't the game (helpers launch.sh starts still count, as its children)
pkg = next((k for path, k in anchors if path in cmd), None) or \
next((k for base, k in bases if base in cmd), None)
root = (pkg, "launcher") if pkg else None
m = REAPER_APPID.search(cmd) if comm == "reaper" and not pkg else None
# any other game Steam runs (its reaper); not SteamVR itself, not a devkit entry of ours
if m and m.group(1) not in by_appid and m.group(1) != STEAMVR_APPID and "/devkit-game/" not in cmd:
root = (STEAM_GAME + m.group(1), "steam")
self.root_cache[key] = root
if self.root_cache[key]:
roots[pid] = self.root_cache[key]
@@ -7116,7 +7127,7 @@ class Monitor:
deps = {d["package"]: d for d in self.deployments(now)}
out = []
for pkg in sorted({v[0] for v in self.roots.values()}):
d = deps.get(pkg) or {}
d = deps.get(pkg) or (self.steam_game(pkg[len(STEAM_GAME):]) if pkg.startswith(STEAM_GAME) else {})
pids = [p["pid"] for p in self.procs if p["game"] == pkg]
cpu = sum(p["cpu"] for p in self.procs if p["game"] == pkg)
rss = sum(p["rss"] for p in self.procs if p["game"] == pkg)
@@ -7136,10 +7147,25 @@ class Monitor:
"appid": d.get("appid"), "elapsed": max(ages) if ages else None,
"cpu": round(max(cpu, 0.0), 1), "mem": mem if mem is not None else rss,
"gpu": round(sum(self.gpu_pct.get(p, 0.0) for p in pids), 1),
"fps": fps, "frame_ms": ms, "processes": len(pids)})
"fps": fps, "frame_ms": ms, "processes": len(pids), "overlay": is_overlay(d)})
# the game in front first: overlay apps (they run next to games) last, then the most recently started
out.sort(key=lambda g: (g["overlay"], g["elapsed"] is None, g["elapsed"] or 0))
self.games = out
return out
def steam_game(self, appid):
"""{title, kind, appid} of a Steam game FramePort didn't install (cached: appmanifest/shortcuts reads)."""
if appid not in self.steam_titles:
app = find_app_id(appid)
name = (app or {}).get("name")
if not name:
for user in steam_users():
name = shortcut_names(user).get(appid)
if name:
break
self.steam_titles[appid] = name or f"App {appid}"
return {"title": self.steam_titles[appid], "kind": "steam", "appid": appid}
def filtered(self, now=None):
procs = self.procs
span = (now - self.scan_prev) if now is not None and self.scan_prev is not None else 0.0
@@ -7285,8 +7311,29 @@ class Monitor:
return {"ended": False, "pid": pid, "name": comm}
def steam_app_running(appid):
"""A Steam reaper runs this app id (a Steam game FramePort didn't install)."""
for name in os.listdir(PROC):
if name.isdigit() and _rd(f"{PROC}/{name}/comm").strip() == "reaper" and \
re.search(rf"\bAppId={appid}\b", _rd(f"{PROC}/{name}/cmdline").replace("\0", " ")):
return True
return False
def monitor_end_game(pkg, wait=6.0):
"""Steam's Exit game for a FramePort game (so Steam's session ends cleanly), then cmd_stop if it still runs."""
"""Steam's Exit game for a FramePort game (so Steam's session ends cleanly), then cmd_stop if it still runs.
A Steam game FramePort didn't install ("steam:<appid>"): Steam's Exit game only."""
if str(pkg).startswith(STEAM_GAME):
appid = str(pkg)[len(STEAM_GAME):]
if not appid.isdigit():
raise AgentError(f"bad Steam app id {appid!r}")
if not steam_app_running(appid):
return {"ended": True, "via": None, "package": pkg, "note": "not running"}
steam_js(f"SteamClient.Apps.TerminateApp('{appid}', false)")
end = time.monotonic() + wait
while time.monotonic() < end and steam_app_running(appid):
time.sleep(0.5)
return {"ended": not steam_app_running(appid), "via": "steam", "package": pkg}
pkg = check_pkg(pkg)
dep = deployment(pkg)
if not dep:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 667 KiB

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.
+1 -1
View File
@@ -93,7 +93,7 @@ shots:
docs: [docs/SHOWCASE.md]
steps:
- go: live
- wait_for: "What the player sees"
- wait_for: "Ready when you are"
- name: install-progress
docs: [docs/SHOWCASE.md]
+2 -2
View File
@@ -62,7 +62,7 @@ scenes:
caption: ["Connect your Steam Frame", "Once: one line on the Frame"]
hold: 1.5
steps:
- click: "Set up the Frame"
- click: "Set up your Frame"
- wait: 1.6
- click: "Start setup"
- wait_for: "Waiting for your Frame to ask"
@@ -111,7 +111,7 @@ scenes:
- hook: {name: pairing_done, args: [1.6]}
- wait_for: "Ready to play"
- wait: 1.2
- hover: "Quest games (Lepton)"
- hover: "Quest games"
- wait: 1.6
- name: add-games
+1
View File
@@ -149,6 +149,7 @@ def main() -> int:
("transit-activity", lambda a: a.show_activity(True))]
if args.fake_frame: # GitHub #90: the Move to… dialog (drives load in the background)
steps.append(("move-dialog", lambda a: (a.page.pop_dialog(), a.move_game(game))))
steps.append(("uninstall-dialog", lambda a: (a.page.pop_dialog(), a.uninstall(game))))
# last: the right-click menu stays open over whatever comes next
steps.append(("library-menu", lambda a: (a.show_activity(False), a.navigate(0), time.sleep(3),
a.library_view.open_menu(game))))
+3 -3
View File
@@ -182,7 +182,7 @@ const ICON: Record<string, string> = {
.view :global(.ok) { color: var(--ok); font-style: normal; } .view :global(.warn) { color: var(--warn); font-style: normal; }
.view :global(.cplay) { position: absolute; right: 8px; bottom: 30px; width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
color: var(--text); background: rgba(22, 24, 30, .6); backdrop-filter: blur(6px); border: 1px solid rgba(237, 238, 242, .25);
opacity: 0; transform: scale(.9); transition: opacity 220ms, transform 220ms var(--ease-back), background-color 220ms; }
opacity: 0; transform: scale(.9); transition: opacity 220ms, transform 220ms var(--ease-spring), background-color 220ms; }
.view :global(.card:hover .cplay) { opacity: 1; transform: none; }
.view :global(.cplay:hover) { background: var(--accent); color: var(--on-accent); }
@@ -246,7 +246,7 @@ const ICON: Record<string, string> = {
.view :global(.sw input) { position: absolute; opacity: 0; pointer-events: none; }
.view :global(.sw i) { flex: none; width: 36px; height: 20px; border-radius: 999px; background: var(--surface-3); position: relative; transition: background-color 150ms; }
.view :global(.sw i::after) { content: ""; position: absolute; left: 3px; top: 3px; width: 14px; height: 14px; border-radius: 50%; background: var(--text-2);
transition: transform 220ms var(--ease-back), background-color 150ms; }
transition: transform 220ms var(--ease-spring), background-color 150ms; }
.view :global(.sw input:checked + i) { background: var(--secondary); }
.view :global(.sw input:checked + i::after) { transform: translateX(16px); background: #fff; }
.view :global(.sw input:focus-visible + i) { outline: 2px solid var(--accent); outline-offset: 2px; }
@@ -445,7 +445,7 @@ const ICON: Record<string, string> = {
.ctx :global(button[disabled]) { color: var(--text-3); cursor: default; }
.toasts { position: absolute; right: 14px; bottom: 14px; z-index: 45; display: grid; gap: 8px; justify-items: end; pointer-events: none; }
.toasts :global(.toast) { max-width: 320px; padding: 10px 14px; border-radius: var(--radius-sm); background: var(--surface-2); border: 1px solid var(--border-strong);
border-left: 3px solid var(--ok); box-shadow: 0 12px 30px rgba(0, 0, 0, .5); font-size: 13px; animation: toastin .3s var(--ease-back) both;
border-left: 3px solid var(--ok); box-shadow: 0 12px 30px rgba(0, 0, 0, .5); font-size: 13px; animation: toastin .3s var(--ease-spring) both;
transition: opacity 300ms, transform 300ms; }
.toasts :global(.toast.info) { border-left-color: var(--secondary); }
.toasts :global(.toast.out) { opacity: 0; transform: translateY(8px); }
+10 -5
View File
@@ -36,18 +36,21 @@ const videos = [
<section class="section tour" id="tour">
<div class="wrap">
<div class="head reveal">
<p class="label"><b>02</b> The app</p>
<h2>Try it before you download it</h2>
<p class="label">Try the app</p>
<h2>Click through FramePort before you download it</h2>
</div>
<div class="reveal"><AppMock games={games} /></div>
<div class="shots reveal">
<details class="disclose more">
<summary>Screenshots and videos of the real app <i aria-hidden="true">▾</i></summary>
<div class="shots">
<p class="watch-k">The real app, screenshot by screenshot</p>
<Carousel slides={shots} />
</div>
<div class="watch reveal">
<div class="watch">
<p class="watch-k">Prefer to watch? The real app on video</p>
<div class="vids">
{videos.map((v) => (
@@ -60,6 +63,7 @@ const videos = [
))}
</div>
</div>
</details>
</div>
@@ -127,6 +131,7 @@ const videos = [
place-items: center; font-size: 24px; color: var(--text); background: rgba(22, 24, 30, .7); cursor: pointer; }
.player .close:hover { background: var(--accent); color: var(--on-accent); }
.shots { margin-top: clamp(40px, 6vw, 64px); display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; }
.more { margin-top: 20px; }
.shots { margin-top: 28px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; }
.watch { margin-top: clamp(40px, 6vw, 64px); }
</style>
+1 -1
View File
@@ -145,7 +145,7 @@ const { slides } = Astro.props;
.contain .zoom :global(img) { object-fit: contain; }
.nav { all: unset; position: absolute; top: 50%; width: 46px; height: 46px; margin-top: -23px; border-radius: 50%; display: grid; place-items: center;
cursor: pointer; color: var(--text); background: rgba(13, 14, 18, .6); backdrop-filter: blur(8px); border: 1px solid rgba(237, 238, 242, .2);
opacity: 0; transition: opacity 220ms, background-color 220ms, transform 320ms var(--ease-back); font-size: 28px; line-height: 1; }
opacity: 0; transition: opacity 220ms, background-color 220ms, transform 320ms var(--ease-spring); font-size: 28px; line-height: 1; }
.prev { left: 14px; } .next { right: 14px; }
.stage:hover .nav, .nav:focus-visible, .car:focus-within .nav { opacity: 1; }
.nav:hover { background: var(--accent); color: var(--on-accent); transform: scale(1.08); }
+14 -43
View File
@@ -6,20 +6,14 @@ import { REPORT_PROBLEM } from '../data/site';
const SYMBOL = { yes: '✓', no: '–', part: '◐', unknown: '?' } as const;
const LABEL = { yes: 'Yes', no: 'No', part: 'Partly', unknown: 'Not stated' } as const;
const ICON = {
code: '<path d="m8 8-4 4 4 4M16 8l4 4-4 4M13.5 5l-3 14"/>',
game: '<path d="M3 9.5A3.5 3.5 0 0 1 6.5 6h11A3.5 3.5 0 0 1 21 9.5v4a3.5 3.5 0 0 1-3.5 3.5h-2.2a2 2 0 0 1-1.7-.95l-.75-1.2a1 1 0 0 0-1.7 0l-.75 1.2A2 2 0 0 1 8.7 17H6.5A3.5 3.5 0 0 1 3 13.5z"/>',
pc: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M12 16v4M8 20h8"/>',
link: '<path d="M5 12.5a9 9 0 0 1 14 0M8 15.5a5 5 0 0 1 8 0M12 19h.01"/><path d="M19 3v4M17 5h4" opacity=".6"/>',
} as const;
---
<section class="section" id="compare">
<div class="wrap">
<div class="head reveal">
<p class="label"><b>04</b> Compared</p>
<h2>Open source, and it patches the games that don't just work</h2>
<p class="lead">Other tools install an app as it is. FramePort also patches Quest games that wouldn't run on the
Frame.</p>
<p class="label">Compare the tools</p>
<h2>FramePort patches the games that don't run as they are</h2>
<p class="lead">Other tools install an app unchanged. FramePort also converts and patches it, and it's free and
open source (GPL-3.0).</p>
</div>
<div class="cmp" data-cmp data-with="1">
@@ -31,10 +25,7 @@ const ICON = {
<ul class="heads reveal">
{HEADLINES.map((h, i) => (
<li class:list={['hcard', { foss: i === 0 }]} data-tilt>
<span class="hicon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" set:html={ICON[h.icon]} />
</span>
<li class:list={['hcard', { foss: i === 0 }]}>
<h3>{h.title}</h3>
<p class="fp"><span class="who">FramePort</span>{h.frameport}</p>
<p class="other o1"><span class="who">FrameDrop</span>{h.others[0]}</p>
@@ -82,20 +73,6 @@ const ICON = {
cmp.dataset.with = b.dataset.withBtn!;
cmp.querySelectorAll('[data-with-btn]').forEach((o) => o.setAttribute('aria-pressed', String(o === b)));
}));
// the cards lean toward the pointer and a light follows it
if (!matchMedia('(prefers-reduced-motion: reduce)').matches && matchMedia('(pointer: fine)').matches) {
document.querySelectorAll<HTMLElement>('[data-tilt]').forEach((card) => {
card.addEventListener('pointermove', (e) => {
const r = card.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width, y = (e.clientY - r.top) / r.height;
card.style.setProperty('--tx', `${((x - 0.5) * 10).toFixed(2)}deg`);
card.style.setProperty('--ty', `${((0.5 - y) * 8).toFixed(2)}deg`);
card.style.setProperty('--mx', `${(x * 100).toFixed(1)}%`);
card.style.setProperty('--my', `${(y * 100).toFixed(1)}%`);
});
card.addEventListener('pointerleave', () => { card.style.setProperty('--tx', '0deg'); card.style.setProperty('--ty', '0deg'); });
});
}
</script>
<style>
@@ -106,25 +83,22 @@ const ICON = {
.with button[aria-pressed="true"] { color: var(--text); border-color: transparent; background: linear-gradient(90deg, rgba(58, 168, 255, .22), rgba(255, 138, 31, .22)); }
.with button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.heads { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px;
perspective: 1000px; }
.heads { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.hcard { position: relative; display: grid; gap: 10px; align-content: start; padding: 22px; border-radius: var(--radius-md);
background: radial-gradient(260px circle at var(--mx, 50%) var(--my, 0%), rgba(255, 138, 31, .10), transparent 70%), var(--surface);
border: 1px solid var(--border); transform: rotateY(var(--tx, 0deg)) rotateX(var(--ty, 0deg));
transition: transform 300ms var(--ease-out), border-color 220ms; }
background: var(--surface); border: 1px solid var(--border); transition: border-color 220ms var(--ease-out); }
.hcard:hover { border-color: rgba(255, 138, 31, .45); }
.hcard.foss { grid-column: 1 / -1; grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr); column-gap: 24px; align-items: start; background: radial-gradient(320px circle at var(--mx, 30%) var(--my, 0%), rgba(61, 214, 140, .14), transparent 70%),
linear-gradient(135deg, rgba(58, 168, 255, .08), rgba(255, 138, 31, .08)), var(--surface); border-color: rgba(61, 214, 140, .35); }
/* the one that matters most: free and open source gets the wide card, a green edge and the portal's light */
.hcard.foss { grid-column: 1 / -1; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 24px; align-items: start;
background: radial-gradient(420px circle at 22% 0%, rgba(61, 214, 140, .12), transparent 70%),
linear-gradient(135deg, rgba(58, 168, 255, .07), rgba(255, 138, 31, .07)), var(--surface); border-color: rgba(61, 214, 140, .35); }
.hcard.foss:hover { border-color: rgba(61, 214, 140, .6); }
.hcard.foss h3 { font-size: clamp(24px, 3vw, 34px); }
.hcard.foss .hicon { grid-row: span 2; width: 56px; height: 56px; }
.hcard.foss h3 { grid-column: 2 / -1; }
.hcard.foss h3 { grid-column: 1 / -1; }
.hcard.foss .other { border-top: 0; padding-top: 0; border-left: 1px dashed var(--border-strong); padding-left: 20px; }
.hicon { width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; color: var(--secondary); background: rgba(58, 168, 255, .12); }
.foss .hicon { color: var(--ok); background: rgba(61, 214, 140, .14); }
.hcard h3 { font-size: 19px; }
.fp { color: var(--text); font-size: 15px; }
.other { color: var(--text-2); font-size: 13.5px; padding-top: 8px; border-top: 1px dashed var(--border-strong); }
.who { display: block; font: 600 10.5px/1 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; margin-bottom: 5px; }
.who { display: block; font: 600 11px/1 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; margin-bottom: 5px; }
.fp .who { color: var(--accent); }
.other .who { color: var(--text-2); }
[data-with="1"] .o2, [data-with="2"] .o1 { display: none; }
@@ -165,9 +139,6 @@ const ICON = {
@media (max-width: 760px) {
.heads { grid-template-columns: minmax(0, 1fr); }
.hcard.foss { grid-template-columns: minmax(0, 1fr); }
.hcard.foss .hicon { grid-row: auto; }
/* one column: the title's "2 / -1" from the wide layout would add a second, implicit column and squeeze
FramePort's text into the first */
.hcard.foss h3 { grid-column: auto; }
.hcard.foss .other { border-left: 0; padding-left: 0; border-top: 1px dashed var(--border-strong); padding-top: 8px; }
th, td { padding: 10px 12px; }
+9 -4
View File
@@ -14,7 +14,7 @@ const latest = latestCheck(games);
<section class="section" id="games">
<div class="wrap">
<div class="head reveal">
<p class="label"><b>03</b> Games</p>
<p class="label">See what runs</p>
<h2>Tested on a Steam Frame</h2>
<p class="lead">
<span data-n="works" data-count>{n.works}</span> games work, <span data-n="issues" data-count>{n.issues}</span> work with issues and
@@ -79,7 +79,7 @@ const latest = latestCheck(games);
<script type="application/json" data-snapshot set:html={JSON.stringify(snapshot).replaceAll('<', '\\u003c')} />
<script>
import { counts, engines, latestCheck, rowsHtml, sortGames, type Snapshot } from '../scripts/catalog';
import { counts, engines, latestCheck, rowsHtml, shuffleGames, type Game, type Snapshot } from '../scripts/catalog';
import { patchHref } from '../scripts/patchlinks';
import { refreshCatalog } from '../scripts/catalog-live';
@@ -139,6 +139,11 @@ const latest = latestCheck(games);
apply();
});
search.addEventListener('input', apply);
// a random order on every visit (working games still first); the built page lists them alphabetically
const rank = new Map<string, number>();
const built = Object.values((JSON.parse(document.querySelector('[data-snapshot]')?.textContent || '{"files":{}}') as
Snapshot).files).map((f) => f.game).filter((g): g is Game => !!g);
if (built.length) list.innerHTML = rowsHtml(shuffleGames(built, rank), patchHref);
apply();
// bring the board up to date with the catalog on GitHub
@@ -152,7 +157,7 @@ const latest = latestCheck(games);
src.title = `The catalog on GitHub's main branch, as of ${when}`;
src.classList.add('live');
if (!live.changed) return;
const games = sortGames(live.games);
const games = shuffleGames(live.games, rank);
list.innerHTML = rowsHtml(games, patchHref);
const n = counts(games);
for (const s of ['works', 'issues', 'unsupported'] as const) set(`[data-n="${s}"]`, String(n[s]));
@@ -221,7 +226,7 @@ const latest = latestCheck(games);
.rows :global(.more[hidden]) { display: none; }
.rows :global(.more p) { max-width: 100ch; line-height: 1.55; }
.rows :global(.pt) { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.rows :global(.pt span) { font: 600 10.5px/1 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--text-2); margin-right: 4px; }
.rows :global(.pt span) { font: 600 11px/1 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--text-2); margin-right: 4px; }
.rows :global(.pt code) { font-size: 12px; padding: 3px 8px; border-radius: 6px; background: rgba(58, 168, 255, .1); color: var(--secondary); }
.rows :global(.pt a) { text-decoration: none; border-radius: 6px; }
.rows :global(.pt a code) { transition: background 160ms; }
+1 -1
View File
@@ -88,7 +88,7 @@ const reports = people.filter((p) => p.issues).length;
.cf button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.cl { margin-top: 6px; color: var(--text-2); font-size: 14px; }
.wall { list-style: none; margin: 22px 0 6px; padding: 10px 0; display: flex; flex-wrap: wrap; gap: 10px; }
.wall li { transition: opacity 300ms, filter 300ms; animation: rise .5s var(--ease-back) both; animation-delay: calc(var(--i) * 18ms); }
.wall li { transition: opacity 300ms, filter 300ms; animation: rise .5s var(--ease-spring) both; animation-delay: calc(var(--i) * 18ms); }
.wall li.dim { opacity: .18; filter: grayscale(1); }
.wall a { --m: 1; display: block; width: 48px; height: 48px; border-radius: 50%; transform: scale(var(--m)); transform-origin: center bottom;
transition: transform 160ms var(--ease-out), box-shadow 200ms; box-shadow: 0 0 0 2px var(--surface), 0 0 0 3px var(--border-strong); }
+1 -1
View File
@@ -37,7 +37,7 @@ const id = big ? 'docsearch-big' : 'docsearch';
.results :global(p) { margin: 6px 10px; font-size: 12px; color: var(--text-3); }
.results :global(a) { display: grid; gap: 3px; padding: 9px 10px; border-radius: var(--radius-sm); text-decoration: none; color: var(--text); }
.results :global(a.on) { background: linear-gradient(90deg, rgba(58, 168, 255, .16), rgba(255, 138, 31, .12)); }
.results :global(small) { font: 500 10.5px/1.2 var(--font-mono); letter-spacing: .06em; text-transform: uppercase; color: var(--secondary); }
.results :global(small) { font: 500 11px/1.2 var(--font-mono); letter-spacing: .06em; text-transform: uppercase; color: var(--secondary); }
.results :global(b) { font-size: 14px; font-weight: 600; }
.results :global(span) { font-size: 12.5px; line-height: 1.45; color: var(--text-2); }
.results :global(mark) { background: transparent; color: var(--accent); font-weight: 600; }
+1 -1
View File
@@ -34,7 +34,7 @@ const joiner = (i: number, n: number) => (i === 0 ? '' : i === n - 1 ? ' and ' :
<section class="section credits" id="credits">
<div class="wrap">
<div class="head reveal">
<p class="label"><b>07</b> Built on</p>
<p class="label">Credits</p>
<h2>Built on open source, by its users</h2>
<p class="lead">FramePort brings these projects together and adds what the Frame needs.
<a href={url('docs/architecture/')}>What FramePort adds</a></p>
+1 -5
View File
@@ -9,7 +9,6 @@ const { games } = Astro.props;
<section class="hero">
<div class="hero-in wrap">
<div class="copy">
<p class="eyebrow">For the Steam Frame</p>
<h1>Your <span class="q">VR games</span>, on the <span class="f">Steam Frame</span></h1>
<p class="lead">
FramePort puts Quest games, PC VR games and Android apps on the Steam Frame and in your Steam library. Connect the
@@ -41,9 +40,6 @@ const { games } = Astro.props;
background: var(--portal-glow); }
.hero-in { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); gap: clamp(24px, 4vw, 64px);
align-items: center; }
.eyebrow { font: 600 12px/1 var(--font-mono); letter-spacing: .16em; text-transform: uppercase; color: var(--text-2);
margin-bottom: 22px; display: flex; align-items: center; gap: 12px; }
.eyebrow::before { content: ""; width: 28px; height: 2px; background: var(--portal); }
h1 { font-size: clamp(42px, 6.4vw, 76px); line-height: 1.0; font-weight: 800; letter-spacing: -0.035em; }
h1 .q { color: var(--secondary); }
h1 .f { color: var(--accent); }
@@ -51,7 +47,7 @@ const { games } = Astro.props;
.ctas { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 32px; }
.platforms { list-style: none; display: flex; flex-wrap: wrap; gap: 4px 16px; padding: 0; margin: 18px 0 0;
font: 500 13px/20px var(--font-sans); }
.platforms a { color: var(--text-2); text-decoration: none; }
.platforms a { display: inline-block; padding-block: 10px; margin-block: -10px; color: var(--text-2); text-decoration: none; }
.platforms a:hover { color: var(--text); text-decoration: underline; }
.platforms a[aria-current] { color: var(--accent); }
.facts { margin-top: 28px; padding-top: 16px; border-top: 1px solid var(--border); font: 400 12px/1.6 var(--font-mono);
+24 -14
View File
@@ -13,7 +13,7 @@ const snippet = `<a href="${pageUrl}?manifest=https%3A%2F%2Fexample.com%2Fmy-gam
<section class="section" id="install">
<div class="wrap">
<div class="head reveal">
<p class="label"><b>05</b> Install</p>
<p class="label">Get started</p>
<h2>From the download to your first game</h2>
</div>
@@ -26,14 +26,20 @@ const snippet = `<a href="${pageUrl}?manifest=https%3A%2F%2Fexample.com%2Fmy-gam
<h3>An "Install with FramePort" button</h3>
<p class="muted">Put it on your download page. A click opens FramePort, which installs your game on the
visitor's Frame.</p>
<div class="badge-row"><InstallBadge id="makers" href={url('install/?manifest=https%3A%2F%2Fframeport.app%2Fdemo%2Fcool-game.json')}
label="Try it: a demo install link (nothing is downloaded)" /><span class="try">← try it</span></div>
<a class="small" href={url('docs/install-button/')}>How to add the button</a>
</div>
<div class="snippet">
<div class="snippet-bar"><span>HTML</span><button type="button" class="btn btn-ghost btn-small" data-copy>Copy</button></div>
<pre><code data-snippet>{snippet}</code></pre>
<div class="makers-demo">
<InstallBadge id="makers" href={url('install/?manifest=https%3A%2F%2Fframeport.app%2Fdemo%2Fcool-game.json')}
label="Try it: a demo install link (nothing is downloaded)" />
<span class="try">Try it: a demo link, nothing is downloaded</span>
</div>
<details class="disclose makers-code">
<summary>Show the HTML <i aria-hidden="true">▾</i></summary>
<div class="snippet">
<div class="snippet-bar"><span>HTML</span><button type="button" class="btn btn-ghost btn-small" data-copy>Copy</button></div>
<pre><code data-snippet>{snippet}</code></pre>
</div>
</details>
</div>
</div>
</section>
@@ -51,17 +57,21 @@ const snippet = `<a href="${pageUrl}?manifest=https%3A%2F%2Fexample.com%2Fmy-gam
<style>
.others { margin-top: 14px; font-size: 14px; color: var(--text-2); }
.small { font-size: 14px; }
.makers { margin-top: clamp(48px, 7vw, 80px); display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); gap: 32px;
padding: 32px; border-radius: var(--radius-md); border: 1px dashed var(--border-strong); background: var(--portal-glow); }
.makers-copy { display: grid; gap: 14px; align-content: start; }
/* the game-maker panel: what it is on the left, the button itself on a small stage on the right, the HTML behind a click */
.makers { margin-top: clamp(48px, 7vw, 80px); display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px 32px;
padding: clamp(22px, 3vw, 32px); border-radius: var(--radius-md); border: 1px dashed var(--border-strong); background: var(--portal-glow); }
.makers-copy { display: grid; gap: 12px; align-content: start; }
.makers-copy .label { margin-bottom: 0; }
.makers-copy h3 { font-size: 24px; }
.badge-row { display: flex; align-items: center; gap: 14px; margin-block: 6px; }
.try { font: 500 13px/1 var(--font-mono); color: var(--text-2); }
.snippet { min-width: 0; border-radius: var(--radius-sm); background: var(--surface-2); border: 1px solid var(--border);
overflow: hidden; align-self: start; }
.makers-copy .muted { max-width: 52ch; }
.makers-demo { display: grid; place-items: center; align-content: center; gap: 14px; padding: 28px 20px; border-radius: var(--radius-md);
background: rgba(5, 6, 8, .45); border: 1px solid var(--border); }
.try { font: 500 12.5px/1.3 var(--font-mono); color: var(--text-2); text-align: center; }
.makers-code { grid-column: 1 / -1; }
.makers-code[open] > summary { margin-bottom: 12px; }
.snippet { min-width: 0; border-radius: var(--radius-sm); background: var(--surface-2); border: 1px solid var(--border); overflow: hidden; }
.snippet-bar { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px 6px 16px;
border-bottom: 1px solid var(--border); font: 600 11px/1 var(--font-mono); letter-spacing: .12em; color: var(--text-2); }
.snippet pre { margin: 0; padding: 16px; overflow-x: auto; font-size: 13px; line-height: 1.6; color: var(--text); }
@media (max-width: 760px) { .makers { grid-template-columns: 1fr; padding: 22px 18px; } }
@media (max-width: 760px) { .makers { grid-template-columns: minmax(0, 1fr); } }
</style>
+2 -2
View File
@@ -34,7 +34,7 @@ const logo = readFileSync(join(process.cwd(), '..', 'src/frameport/ui/icons/logo
<style>
.ib { --mx: 56%; --my: 65%; position: relative; display: inline-flex; align-items: center; gap: 10px; width: 216px; height: 60px; padding: 0 14px 0 10px;
box-sizing: border-box; border-radius: 12px; overflow: hidden; text-decoration: none; isolation: isolate;
background: #0d0e12; border: 1px solid #2a2d37; transition: transform 320ms var(--ease-back), border-color 220ms, box-shadow 220ms; }
background: #0d0e12; border: 1px solid #2a2d37; transition: transform 320ms var(--ease-spring), border-color 220ms, box-shadow 220ms; }
.ib:hover { transform: translateY(-2px) scale(1.03); border-color: rgba(255, 138, 31, .6); box-shadow: 0 10px 30px rgba(0, 0, 0, .5), 0 0 24px rgba(255, 138, 31, .25); }
.ib:active { transform: scale(.97); }
.ib:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
@@ -46,7 +46,7 @@ const logo = readFileSync(join(process.cwd(), '..', 'src/frameport/ui/icons/logo
.ib:hover .ib-core { opacity: 1; }
.ib.pressed::after { content: ""; position: absolute; left: var(--mx); top: var(--my); width: 20px; height: 20px; margin: -10px; border-radius: 50%;
border: 2px solid var(--secondary); animation: ibring .6s var(--ease-out) forwards; pointer-events: none; }
.ib :global(.ib-logo) { width: 40px; height: 40px; flex: none; transition: transform 600ms var(--ease-back); }
.ib :global(.ib-logo) { width: 40px; height: 40px; flex: none; transition: transform 600ms var(--ease-spring); }
.ib:hover :global(.ib-logo) { transform: rotate(-8deg) scale(1.06); }
.ib-text { display: grid; gap: 2px; line-height: 1; }
.ib-text small { font: 700 9.5px/1 var(--font-sans); letter-spacing: .14em; text-transform: uppercase; color: #a7abb7; }
+21 -9
View File
@@ -6,6 +6,8 @@ import HeadsetModel from './HeadsetModel.astro';
import { DOWNLOADS, RELEASES, downloadPage, url } from '../data/site';
const FIRST = Object.fromEntries(DOWNLOADS.map((d) => [d.os, d.first]));
// what the unpacked folder holds to start, per platform (the Downloads scene shows it)
const RUN: Record<string, string> = { windows: 'FramePort.exe', macos: 'FramePort.app', linux: 'FramePort', 'linux-arm64': 'FramePort' };
const LINE = 'curl -fsSL https://frameport.app/s | bash';
const steps = [
{ side: 'pc', where: 'On your PC', title: 'Download FramePort' },
@@ -35,7 +37,7 @@ const steps = [
<p>Windows, macOS or Linux. Unpack it anywhere and start it. No installer; it updates itself.</p>
<div class="dls">
{DOWNLOADS.map((d) => (
<a class="dl" href={downloadPage(d.os)} data-os={d.os} data-first={FIRST[d.os]}>
<a class="dl" href={downloadPage(d.os)} data-os={d.os} data-first={FIRST[d.os]} data-asset={d.asset} data-run={RUN[d.os]}>
<strong>{d.label}</strong><span>{d.detail}</span>
</a>
))}
@@ -45,8 +47,8 @@ const steps = [
</div>
<div class="scene pcwin" aria-hidden="true">
<div class="wbar"><i></i><i></i><i></i><span>Downloads</span></div>
<div class="dlrow"><span class="zip">ZIP</span><div><b data-zip>FramePort-windows-x64.zip</b><span class="bar"><i></i></span></div></div>
<div class="folder"><span class="fico"></span><b>FramePort</b><span class="exe">FramePort.exe</span></div>
<div class="dlrow"><span class="zip" data-kind>ZIP</span><div><b data-zip>FramePort-windows-x64.zip</b><span class="bar"><i></i></span></div></div>
<div class="folder"><span class="fico"></span><b>FramePort</b><span class="exe" data-run-out>FramePort.exe</span></div>
</div>
</section>
@@ -88,6 +90,8 @@ const steps = [
<p>Open Konsole: SteamVR dashboard → <b>Launch a program → Desktop</b>, then app menu → <b>System → Konsole</b>.
Run this line:</p>
<div class="cmd"><code><span class="prompt">$</span>{LINE}</code><button type="button" class="btn btn-ghost btn-small" data-copy-line={LINE}>Copy</button></div>
<p class="calm">It finds FramePort on your PC and waits: nothing changes on the Frame until you click
<b>Allow</b>. Then it turns on Developer Mode. No password, no root. <a href={url('setup.sh')}>Read the script</a></p>
<p class="small">No keyboard? Open <a href={url('setup/')}>the setup page</a> in Chromium on the Frame (Steam's
<b>+</b> on the taskbar installs it) and copy the line.</p>
</div>
@@ -267,9 +271,14 @@ const steps = [
const setFirst = (d?: HTMLElement) => {
if (!d) return;
if (firstOut) firstOut.textContent = d.dataset.first!;
// the real file for that platform (the link itself is the download page, /download/?os=…)
const asset = d.dataset.asset ?? 'FramePort-windows-x64.zip';
const zip = wt.querySelector('[data-zip]');
const a = d.getAttribute('href') ?? '';
if (zip) zip.textContent = a.split('/').pop() || 'FramePort.zip';
if (zip) zip.textContent = asset;
const kind = wt.querySelector('[data-kind]');
if (kind) kind.textContent = asset.endsWith('.tar.gz') ? 'TAR.GZ' : 'ZIP';
const run = wt.querySelector('[data-run-out]');
if (run) run.textContent = d.dataset.run ?? 'FramePort';
};
requestAnimationFrame(() => setFirst(mine()));
dls.forEach((d) => {
@@ -294,7 +303,7 @@ const steps = [
.pip .k { flex: none; width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font: 800 14px/1 var(--font-display);
background: var(--surface-3); color: var(--text-2); transition: background-color 220ms, color 220ms; }
.pip .t { display: grid; gap: 1px; min-width: 0; font: 600 13px/1.2 var(--font-sans); color: var(--text-2); }
.pip .t small { font: 500 10.5px/1.2 var(--font-mono); letter-spacing: .06em; text-transform: uppercase; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pip .t small { font: 500 11px/1.2 var(--font-mono); letter-spacing: .06em; text-transform: uppercase; color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pip[aria-selected="true"] { border-color: rgba(255, 138, 31, .55); background: linear-gradient(90deg, rgba(58, 168, 255, .1), rgba(255, 138, 31, .1)); }
.pip[aria-selected="true"] .t { color: var(--text); }
.pip.pc[aria-selected="true"] .k, .pip.pc.past .k { background: var(--secondary); color: var(--on-accent); }
@@ -306,12 +315,15 @@ const steps = [
.pane.on { animation: slide .45s var(--ease-out); }
@keyframes slide { from { opacity: 0; transform: translateX(calc(var(--dir) * 24px)); } to { opacity: 1; transform: none; } }
.txt { display: grid; gap: 12px; align-content: start; min-width: 0; }
.way-line:not([hidden]) { display: grid; gap: 12px; }
.txt h3 { font-size: clamp(22px, 2.6vw, 28px); }
.txt p { color: var(--text-2); }
.txt b { color: var(--text); font-weight: 600; }
.ey { font: 600 11px/1 var(--font-mono); letter-spacing: .14em; text-transform: uppercase; }
.ey.pc { color: var(--secondary); } .ey.fr { color: var(--accent); }
.small { font-size: 14px; }
/* the reassurance next to the one command that changes the Frame: brighter than the hints around it */
.txt p.calm { font-size: 14px; color: var(--text); }
.first { font-size: 14px; min-height: 3em; }
.dls { display: flex; flex-wrap: wrap; gap: 8px; }
.dl { display: grid; gap: 2px; padding: 9px 14px; min-width: 130px; border-radius: var(--radius-sm); text-decoration: none;
@@ -334,7 +346,7 @@ const steps = [
.wbar i { width: 9px; height: 9px; border-radius: 50%; background: var(--surface-3); }
.wbar span { margin-left: 6px; }
.dlrow { display: flex; gap: 12px; align-items: center; padding: 18px; }
.zip { flex: none; width: 40px; height: 48px; border-radius: 6px; display: grid; place-items: center; font: 800 11px/1 var(--font-display);
.zip { flex: none; min-width: 40px; padding-inline: 5px; height: 48px; border-radius: 6px; display: grid; place-items: center; font: 800 11px/1 var(--font-display);
background: linear-gradient(160deg, var(--secondary-soft), var(--surface-3)); color: var(--secondary); }
.dlrow > div { display: grid; gap: 8px; flex: 1; min-width: 0; }
.dlrow b { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -403,7 +415,7 @@ const steps = [
.run .ticks li:nth-child(1) { animation-delay: .3s; } .run .ticks li:nth-child(2) { animation-delay: 1s; }
.run .ticks li:nth-child(3) { animation-delay: 1.7s; } .run .ticks li:nth-child(4) { animation-delay: 2.4s; }
@keyframes tick { to { opacity: 1; border-color: rgba(61, 214, 140, .35); } }
.run .ticks li::before { animation: tickdot .4s var(--ease-back) forwards; animation-delay: inherit; }
.run .ticks li::before { animation: tickdot .4s var(--ease-spring) forwards; animation-delay: inherit; }
@keyframes tickdot { to { background: var(--ok); transform: scale(1.1); } }
.conn-card { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 10px; background: var(--sidebar);
border: 1px solid var(--border); opacity: 0; transform: translateY(8px); font-size: 13px; }
@@ -424,7 +436,7 @@ const steps = [
.run .inst .bar i { animation: grow 2.2s var(--ease-out) .3s forwards; }
.playb { justify-self: start; padding: 9px 14px; border-radius: 8px; background: var(--accent); color: var(--on-accent); font: 600 13px/1 var(--font-sans);
opacity: 0; transform: scale(.9); }
.run .playb { animation: pop .4s var(--ease-back) 2.6s forwards; }
.run .playb { animation: pop .4s var(--ease-spring) 2.6s forwards; }
@keyframes pop { to { opacity: 1; transform: none; } }
.video { position: relative; display: block; border-radius: var(--radius-sm); overflow: hidden; border: 1px solid var(--border); text-decoration: none;
max-width: 420px; transition: transform 220ms var(--ease-out); }
+60 -17
View File
@@ -5,6 +5,16 @@ import { REPO, downloadPage, url } from '../data/site';
interface Props { home?: boolean }
const { home = true } = Astro.props;
const at = (hash: string) => (home ? hash : url(hash));
const links = [
{ href: at('#how'), label: 'How it works' },
{ href: at('#games'), label: 'Games' },
{ href: at('#compare'), label: 'Compare' },
{ href: at('#install'), label: 'Get started' },
{ href: url('setup/'), label: 'Setup' },
{ href: url('changelog/'), label: "What's new" },
{ href: url('docs/'), label: 'Docs' },
{ href: REPO, label: 'GitHub' },
];
---
<header class="nav">
<div class="nav-in wrap">
@@ -12,20 +22,30 @@ const at = (hash: string) => (home ? hash : url(hash));
<img src={url('favicon.svg')} alt="" width="30" height="30" />
<Wordmark id="nav" />
</a>
<nav aria-label="Sections">
<a href={at('#how')}>How it works</a>
<a href={at('#games')}>Games</a>
<a href={at('#compare')}>Compared</a>
<a href={at('#install')}>Install</a>
<a href={url('setup/')}>Setup</a>
<a href={url('changelog/')}>What's new</a>
<a href={url('docs/')}>Docs</a>
<a href={REPO}>GitHub</a>
<nav class="links" aria-label="Sections">
{links.map((l) => <a href={l.href}>{l.label}</a>)}
</nav>
<a class="btn btn-primary btn-small" href={downloadPage()} data-download><span data-download-label>Download FramePort</span></a>
<button type="button" class="menu-btn" popovertarget="site-menu" aria-controls="site-menu">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16" /></svg>
Menu
</button>
<a class="btn btn-primary btn-small dl" href={downloadPage()} data-download><span data-download-label>Download FramePort</span></a>
</div>
<!-- narrow windows and phones: the same links in a panel under the bar (Esc or a click outside closes it) -->
<div id="site-menu" class="menu" popover>
<nav aria-label="Sections">
{links.map((l) => <a href={l.href}>{l.label}</a>)}
</nav>
<a class="btn btn-primary" href={downloadPage()} data-download><span data-download-label>Download FramePort</span></a>
</div>
</header>
<script>
// a link inside the menu closes it (in-page links don't navigate away)
const menu = document.getElementById('site-menu');
menu?.addEventListener('click', (e) => { if ((e.target as Element).closest('a')) menu.hidePopover?.(); });
</script>
<style>
.nav {
position: sticky; top: 0; z-index: 50;
@@ -37,13 +57,36 @@ const at = (hash: string) => (home ? hash : url(hash));
.nav-in { display: flex; align-items: center; gap: 24px; min-height: 64px; }
.brand { display: inline-flex; align-items: center; gap: 10px; text-decoration: none; }
.brand img { border-radius: 7px; }
nav { display: flex; gap: 2px; margin-left: auto; }
nav a { padding: 8px 12px; border-radius: var(--radius-sm); color: var(--text-2); text-decoration: none;
font: 500 14px/20px var(--font-sans); }
nav a:hover { color: var(--text); background: rgba(237, 238, 242, 0.06); }
.links { display: flex; gap: 2px; margin-left: auto; }
.links a, .menu a:not(.btn) { padding: 8px 12px; border-radius: var(--radius-sm); color: var(--text-2); text-decoration: none;
font: 500 14px/20px var(--font-sans); white-space: nowrap; }
.links a:hover, .menu a:not(.btn):hover { color: var(--text); background: rgba(237, 238, 242, 0.06); }
[data-download-label] { font-size: 13px; }
@media (max-width: 760px) {
nav { display: none; }
.btn { margin-left: auto; }
.menu-btn { display: none; align-items: center; gap: 8px; margin-left: auto; min-height: 44px; padding: 0 14px;
border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: transparent; color: var(--text);
font: 600 14px/1 var(--font-sans); cursor: pointer; }
.menu-btn svg { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.menu-btn:hover { background: rgba(237, 238, 242, 0.06); }
.menu-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.menu { position: fixed; inset: 66px 0 auto 0; margin: 0; width: 100%; max-height: calc(100dvh - 66px); overflow-y: auto;
padding: 8px var(--gutter) 20px; border: 0; border-bottom: 1px solid var(--border-strong); background: var(--sidebar);
color: var(--text); box-shadow: 0 18px 40px rgba(0, 0, 0, .55); }
.menu nav { display: grid; margin-bottom: 14px; }
.menu a:not(.btn) { display: flex; align-items: center; min-height: 48px; font-size: 16px; color: var(--text);
border-bottom: 1px solid var(--border); border-radius: 0; }
.menu .btn { width: 100%; }
.menu .btn [data-download-label] { font-size: 15px; }
/* the eight links fit one row only in wide windows; below that they move into the menu */
@media (max-width: 1120px) {
.links { display: none; }
.menu-btn { display: inline-flex; }
.nav-in { gap: 12px; }
}
/* phones: the bar keeps the brand and the menu; the download (for the PC) waits in the menu */
@media (max-width: 560px) {
.nav-in > .dl { display: none; }
}
</style>
+2 -2
View File
@@ -12,12 +12,12 @@
</aside>
<style>
.notice-band { padding-block: 8px clamp(40px, 6vw, 72px); }
.notice-band { padding-block: clamp(24px, 3vw, 32px) clamp(40px, 6vw, 72px); }
.notice-in { display: flex; gap: 16px; align-items: flex-start; padding: 18px 22px; border-radius: var(--radius-md);
background: rgba(92, 208, 230, 0.06); border: 1px solid rgba(92, 208, 230, 0.3); }
.mark { flex: none; width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font: 800 15px/1 var(--font-display);
color: var(--bg); background: var(--info); }
.notice-in { flex-wrap: wrap; }
p { flex: 1 1 calc(100% - 60px); font-size: 15px; line-height: 1.6; color: var(--text); max-width: 100ch; }
p { flex: 1 1 calc(100% - 60px); font-size: 15px; line-height: 1.6; color: var(--text); max-width: 64ch; }
.poc { flex-basis: 100%; padding-left: 44px; font-size: 13px; color: var(--text-2); }
</style>
+11 -6
View File
@@ -39,8 +39,9 @@ const frame: Stage[] = [
<section class="section" id="how">
<div class="wrap">
<div class="head reveal">
<p class="label"><b>01</b> How it works</p>
<h2>Ten stages between your PC and the Steam Frame<br /><span class="muted">You click once</span></h2>
<p class="label">See how it works</p>
<h2>What happens when you click Install on Frame</h2>
<p class="lead">Ten stages: seven on your PC, three on the Frame. You only click once.</p>
</div>
<div class="log reveal" data-log>
@@ -169,10 +170,12 @@ const frame: Stage[] = [
.ok { font: 600 11px/1 var(--font-mono); color: var(--ok); text-transform: uppercase; justify-self: end; }
/* scrolling completes the install line by line (scripts/interactions.ts marks .done), like the app's activity log */
.log.scrubbed .stage { opacity: .32; transition: opacity 450ms var(--ease-out), background-color 300ms; }
.log.scrubbed .stage.done { opacity: 1; }
.log.scrubbed .stage.done .g { animation: gpop .5s var(--ease-back); }
.log.scrubbed .ok { opacity: 0; transform: scale(.5); transition: opacity 300ms, transform 400ms var(--ease-back); }
/* a stage not reached yet: its mark and name wait, its text stays readable */
.log.scrubbed .stage .g { transition: opacity 450ms var(--ease-out); }
.log.scrubbed .stage:not(.done) .g { opacity: .4; }
.log.scrubbed .stage:not(.done) :is(.n, .t, .d) { color: var(--text-3); }
.log.scrubbed .stage.done .g { animation: gpop .5s var(--ease-spring); }
.log.scrubbed .ok { opacity: 0; transform: scale(.5); transition: opacity 300ms, transform 400ms var(--ease-spring); }
.log.scrubbed .stage.done .ok { opacity: 1; transform: none; }
.log.complete .log-title::after { content: "· done"; margin-left: 4px; color: var(--ok); font: 600 12px/1 var(--font-mono); }
@keyframes gpop { 40% { transform: scale(1.25); } }
@@ -211,4 +214,6 @@ const frame: Stage[] = [
.t { grid-column: 2 / 4; }
.d { grid-column: 2 / 4; text-align: left; }
}
/* phones: what each stage does is enough; the technical detail (file names, patch ids) stays on wider screens */
@media (max-width: 600px) { .d { display: none; } }
</style>
+4 -14
View File
@@ -1,7 +1,7 @@
---
// The home page's look at the latest release (src/data/releases.ts): its first changes, the few releases before it
// and the dev build in testing. The full list is the changelog page.
import { LATEST, NEXT, RELEASES } from '../data/releases';
// The home page's look at the latest release (src/data/releases.ts): its first changes and the three releases before
// it. The full list (and the dev build in testing) is the changelog page.
import { LATEST, RELEASES } from '../data/releases';
import { url } from '../data/site';
const before = RELEASES.slice(1, 4);
@@ -12,7 +12,7 @@ const more = LATEST ? LATEST.changes - LATEST.highlights.length : 0;
<section class="section" id="whats-new">
<div class="wrap">
<div class="head reveal">
<p class="label"><b>06</b> What's new</p>
<p class="label">What's new</p>
<h2>FramePort {LATEST.version}</h2>
</div>
@@ -35,12 +35,6 @@ const more = LATEST ? LATEST.changes - LATEST.highlights.length : 0;
</li>
))}
</ol>
{NEXT && (
<a class="soon" href={url('changelog/#next')}>
<span class="pill pill-pc">In testing</span>
<span>Coming next: <b>{NEXT.version}</b></span>
</a>
)}
<a class="btn btn-secondary btn-small all" href={page()}>All releases</a>
</div>
</div>
@@ -75,10 +69,6 @@ const more = LATEST ? LATEST.changes - LATEST.highlights.length : 0;
.mini time { justify-self: end; align-self: center; }
.mini .h { grid-column: 1 / -1; color: var(--text-2); font-size: 14px; line-height: 1.45;
display: -webkit-box; -webkit-line-clamp: 1; line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.soon { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-radius: var(--radius-md);
border: 1px dashed var(--secondary-line); color: var(--text-2); font-size: 14px; text-decoration: none; }
.soon b { color: var(--text); font: 500 13px var(--font-mono); }
.soon:hover { background: rgba(58, 168, 255, .06); text-decoration: none; }
.all { justify-self: start; }
@media (max-width: 860px) { .wn { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) { .now { transition: none; } .now:hover { transform: none; } }
+111 -12
View File
@@ -1,14 +1,23 @@
{
"updated": "2026-10-11",
"updated": "2026-10-12",
"people": [
{
"login": "spoopyghosty0",
"avatar": "https://avatars.githubusercontent.com/u/336754034?v=4",
"url": "https://github.com/spoopyghosty0",
"commits": 523,
"issues": 4,
"commits": 542,
"issues": 5,
"prs": 3,
"total": 550
"total": 572
},
{
"login": "Klownicle",
"avatar": "https://avatars.githubusercontent.com/u/6896081?v=4",
"url": "https://github.com/Klownicle",
"commits": 0,
"issues": 20,
"prs": 0,
"total": 60
},
{
"login": "StevenPartridge",
@@ -29,13 +38,13 @@
"total": 45
},
{
"login": "Klownicle",
"avatar": "https://avatars.githubusercontent.com/u/6896081?v=4",
"url": "https://github.com/Klownicle",
"login": "Sheebalove",
"avatar": "https://avatars.githubusercontent.com/u/176067652?v=4",
"url": "https://github.com/Sheebalove",
"commits": 0,
"issues": 13,
"issues": 11,
"prs": 0,
"total": 39
"total": 33
},
{
"login": "inseend",
@@ -100,6 +109,15 @@
"prs": 0,
"total": 15
},
{
"login": "Scoota23",
"avatar": "https://avatars.githubusercontent.com/u/27732436?v=4",
"url": "https://github.com/Scoota23",
"commits": 0,
"issues": 5,
"prs": 0,
"total": 15
},
{
"login": "eepykith",
"avatar": "https://avatars.githubusercontent.com/u/338898488?v=4",
@@ -226,6 +244,15 @@
"prs": 0,
"total": 6
},
{
"login": "ganona",
"avatar": "https://avatars.githubusercontent.com/u/112239200?v=4",
"url": "https://github.com/ganona",
"commits": 0,
"issues": 2,
"prs": 0,
"total": 6
},
{
"login": "GreySyntax",
"avatar": "https://avatars.githubusercontent.com/u/242626?v=4",
@@ -244,6 +271,15 @@
"prs": 0,
"total": 6
},
{
"login": "Minibattle",
"avatar": "https://avatars.githubusercontent.com/u/67839290?v=4",
"url": "https://github.com/Minibattle",
"commits": 0,
"issues": 2,
"prs": 0,
"total": 6
},
{
"login": "puppet-head",
"avatar": "https://avatars.githubusercontent.com/u/8073775?v=4",
@@ -289,6 +325,15 @@
"prs": 0,
"total": 3
},
{
"login": "Aryetis",
"avatar": "https://avatars.githubusercontent.com/u/4246001?v=4",
"url": "https://github.com/Aryetis",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "bcain2005-gif",
"avatar": "https://avatars.githubusercontent.com/u/258048498?v=4",
@@ -316,6 +361,15 @@
"prs": 0,
"total": 3
},
{
"login": "DolphinOfficial",
"avatar": "https://avatars.githubusercontent.com/u/92276540?v=4",
"url": "https://github.com/DolphinOfficial",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "dontstealthisplease-gif",
"avatar": "https://avatars.githubusercontent.com/u/337959488?v=4",
@@ -343,6 +397,15 @@
"prs": 0,
"total": 3
},
{
"login": "felis-lynx",
"avatar": "https://avatars.githubusercontent.com/u/106830854?v=4",
"url": "https://github.com/felis-lynx",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "Froghut",
"avatar": "https://avatars.githubusercontent.com/u/1702171?v=4",
@@ -370,6 +433,15 @@
"prs": 0,
"total": 3
},
{
"login": "hokey1981",
"avatar": "https://avatars.githubusercontent.com/u/42741984?v=4",
"url": "https://github.com/hokey1981",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "InsideCat02",
"avatar": "https://avatars.githubusercontent.com/u/111202797?v=4",
@@ -398,9 +470,18 @@
"total": 3
},
{
"login": "Minibattle",
"avatar": "https://avatars.githubusercontent.com/u/67839290?v=4",
"url": "https://github.com/Minibattle",
"login": "Mitchlett",
"avatar": "https://avatars.githubusercontent.com/u/46537377?v=4",
"url": "https://github.com/Mitchlett",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "Momofier",
"avatar": "https://avatars.githubusercontent.com/u/13684228?v=4",
"url": "https://github.com/Momofier",
"commits": 0,
"issues": 1,
"prs": 0,
@@ -415,6 +496,15 @@
"prs": 0,
"total": 3
},
{
"login": "Obliviate28",
"avatar": "https://avatars.githubusercontent.com/u/341108069?v=4",
"url": "https://github.com/Obliviate28",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "Puchor",
"avatar": "https://avatars.githubusercontent.com/u/46112576?v=4",
@@ -433,6 +523,15 @@
"prs": 0,
"total": 3
},
{
"login": "ssgod101",
"avatar": "https://avatars.githubusercontent.com/u/49173466?v=4",
"url": "https://github.com/ssgod101",
"commits": 0,
"issues": 1,
"prs": 0,
"total": 3
},
{
"login": "TainakaRitsu",
"avatar": "https://avatars.githubusercontent.com/u/12560774?v=4",
+25 -1
View File
@@ -1,6 +1,30 @@
{
"updated": "2026-10-11",
"updated": "2026-10-12",
"releases": [
{
"tag": "v1.0.4",
"name": "FramePort v1.0.4",
"date": "2026-10-11",
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.0.4",
"dev": false,
"notes": "FramePort v1.0.4: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **Launch tests no longer report \"Failed to launch\"** for games that run fine but crash once the test ends their VR session because no headset is worn (e.g. BlazeRush).\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n"
},
{
"tag": "v1.0.3",
"name": "FramePort v1.0.3",
"date": "2026-10-11",
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.0.3",
"dev": false,
"notes": "FramePort v1.0.3: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **One FramePort at a time:** starting it again brings the open window to the front, and FramePort fully closes with its window.\n- **After an update, FramePort restarts with a working window** instead of a black one (Windows).\n- The update dialogs' \"All releases\" and \"Release page\" buttons work again.\n- When several play sessions are checked at once, one message lists them instead of one per game.\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n"
},
{
"tag": "v1.0.2",
"name": "FramePort v1.0.2",
"date": "2026-10-11",
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.0.2",
"dev": false,
"notes": "FramePort v1.0.2: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **Progress is kept:** games that save under your Meta user (for example Walkabout Mini Golf) no longer start with an empty profile each time. Click **Update on Frame** on their page.\n- A new troubleshooting FAQ in the docs: where a game's settings are and what to try when a game misbehaves.\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n"
},
{
"tag": "v1.0.1",
"name": "FramePort v1.0.1",
+6 -2
View File
@@ -67,16 +67,20 @@ const feed = new URL(url('changelog/feed.xml'), Astro.site);
if (label) label.textContent = `Download for ${pick.label}`;
}
for (const el of document.querySelectorAll(`[data-os="${pick.os}"]`)) el.setAttribute('aria-current', 'true');
} else if (/Android|iPhone|iPad|Mobile/i.test(ua)) {
// FramePort runs on a PC: on a phone the buttons lead to the download page's "open this on your PC" state
for (const el of document.querySelectorAll('[data-download-label]')) el.textContent = 'Get it on your PC';
}
</script>
<script>
import { initInteractions } from '../scripts/interactions';
// Sections fade in once as they reach the viewport (not with reduced motion: CSS shows them at once).
// Sections settle in once as they reach the viewport: a short, small rise (with reduced motion: CSS shows them at
// once). Everything is visible without the script.
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); }
}
}, { rootMargin: '0px 0px -12% 0px' });
}, { rootMargin: '0px 0px -8% 0px' });
document.querySelectorAll('.reveal').forEach((el) => io.observe(el));
initInteractions();
</script>
+4 -1
View File
@@ -59,6 +59,8 @@ const builds = DOWNLOADS.map((d) => ({ os: d.os, label: d.label, detail: d.detai
<span class="sha" data-sha-wrap hidden>SHA-256 <code data-sha></code><button type="button" data-copysha>Copy</button></span>
<a href={RELEASES}>Release notes</a>
</p>
<p class="trust">Windows and macOS warn the first time you open FramePort: it's signed by its developer, not with a
paid certificate. Each release lists every file's SHA-256 in SHA256SUMS.txt to check it.</p>
</section>
<section class="wrap next" aria-labelledby="next-h">
@@ -284,7 +286,7 @@ const builds = DOWNLOADS.map((d) => ({ os: d.os, label: d.label, detail: d.detai
.arrive { position: absolute; left: 50%; top: 50%; width: 54px; height: 54px; margin: -27px; display: grid; place-items: center; border-radius: 50%;
background: var(--accent); color: var(--on-accent); font: 800 24px/1 var(--font-display); box-shadow: 0 0 40px rgba(255, 138, 31, .7);
transform: translateX(clamp(175px, 22vw, 300px)) scale(0); opacity: 0; z-index: 2; }
[data-state="sent"] .arrive { animation: arrive 700ms 780ms var(--ease-back) forwards; }
[data-state="sent"] .arrive { animation: arrive 700ms 780ms var(--ease-spring) forwards; }
.flare { position: absolute; left: 50%; top: 50%; width: 30px; height: 30px; margin: -15px; border-radius: 50%; opacity: 0; z-index: 1;
background: radial-gradient(circle, #fff, rgba(255, 138, 31, .8) 30%, transparent 70%); }
[data-state="sent"] .flare { animation: flare 900ms 560ms ease-out forwards; }
@@ -327,6 +329,7 @@ const builds = DOWNLOADS.map((d) => ({ os: d.os, label: d.label, detail: d.detai
[data-state="counting"] .builds, [data-state="sent"] .builds { opacity: .7; }
.facts { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px 16px; font-size: 13px; color: var(--text-3); margin: 0; }
.facts[hidden], .sha[hidden] { display: none; }
.trust { max-width: 62ch; margin: 14px auto 0; text-align: center; font-size: 13.5px; line-height: 1.55; color: var(--text-2); }
.facts > span:empty { display: none; }
.sha code { font-size: 12px; color: var(--text-2); }
.sha button { all: unset; cursor: pointer; margin-left: 6px; color: var(--secondary); }
+1 -1
View File
@@ -147,7 +147,7 @@ const line = 'curl -fsSL https://frameport.app/s | bash';
.small { font-size: 14px; }
.cmd.big code { font-size: 15px; }
.copy { all: unset; display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: var(--radius-sm); cursor: pointer;
background: var(--accent); color: var(--on-accent); font: 600 14px/1 var(--font-sans); transition: filter 200ms, transform 300ms var(--ease-back); }
background: var(--accent); color: var(--on-accent); font: 600 14px/1 var(--font-sans); transition: filter 200ms, transform 300ms var(--ease-spring); }
.copy:hover { filter: brightness(1.08); }
.copy:active { transform: scale(.95); }
.copy.done { background: var(--ok); }
+16 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { rowHtml, short, sortGames, toGame } from './catalog.ts';
import { rowHtml, short, shuffleGames, sortGames, toGame } from './catalog.ts';
test('catalog entries become rows like docs/GAMES.md', () => {
assert.equal(toGame({ title: 'x', status: 'maybe' }), null);
@@ -26,6 +26,21 @@ test('order: works, issues, unsupported, then title', () => {
assert.deepEqual(sortGames(games).map((g) => g.title), ['a', 'z', 'a', 'b']);
});
test('shuffle: status groups stay in order, a random order inside, kept across calls', () => {
const games = ['unsupported:b', 'works:z', 'issues:a', 'works:a', 'works:m', 'issues:q'].map((s) => {
const [status, title] = s.split(':');
return toGame({ status, title, package: `p.${status}.${title}` });
});
const seq = [0.9, 0.1, 0.5, 0.3, 0.7, 0.2];
const rank = new Map();
const first = shuffleGames(games, rank, () => seq.shift());
assert.deepEqual(first.map((g) => g.status), ['works', 'works', 'works', 'issues', 'issues', 'unsupported']);
assert.deepEqual(first.filter((g) => g.status === 'works').map((g) => g.title), ['z', 'a', 'm']);
// the same page reorders the same way (the live refresh), new games get their own place
const again = shuffleGames([...games].reverse(), rank, () => 0.0);
assert.deepEqual(again.map((g) => g.title), first.map((g) => g.title));
});
test('rows escape what comes from GitHub', () => {
const html = rowHtml(toGame({ title: '<img src=x onerror=alert(1)>', status: 'works', engine: '"x' }));
assert.ok(!html.includes('<img'));
+8
View File
@@ -69,6 +69,14 @@ export function sortGames(games: Game[]): Game[] {
|| a.title.localeCompare(b.title, 'en', { sensitivity: 'base' }));
}
/** Working games first, then with issues, then not running; a random order inside each group, so every visit leads
* with other games. `rank` keeps each game's place for the page's lifetime (the live refresh doesn't reshuffle). */
export function shuffleGames(games: Game[], rank = new Map<string, number>(), rand: () => number = Math.random): Game[] {
const key = (g: Game) => g.package || g.title;
for (const g of games) if (!rank.has(key(g))) rank.set(key(g), rand());
return [...games].sort((a, b) => ORDER[a.status] - ORDER[b.status] || rank.get(key(a))! - rank.get(key(b))!);
}
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
/** Where a recipe chip links to (its entry in the patch reference), or null for none. */
+4 -1
View File
@@ -68,9 +68,12 @@ export function initFrameSide(stage: HTMLElement, host: HTMLElement, firstTitle:
}
const webgl = hasWebGL();
// data saver or a slow connection: no three.js download, the CSS scene underneath stays
const conn = (navigator as Navigator & { connection?: { saveData?: boolean; effectiveType?: string } }).connection;
const lean = conn?.saveData === true || /(^|-)(2g|3g)$/.test(conn?.effectiveType ?? '');
new IntersectionObserver((es) => {
visible = es.some((e) => e.isIntersecting);
if (visible && !side && !loading && webgl) start();
if (visible && !side && !loading && webgl && !lean) start();
kick();
}, { rootMargin: '120px' }).observe(stage);
document.addEventListener('visibilitychange', kick);
+6 -36
View File
@@ -1,29 +1,9 @@
// Small reactions all over the page (styles in global.css under "everything reacts"). None of them is needed to read
// or use the page; with reduced motion only the ones that don't move stay (count-ups show their number at once).
// Small reactions all over the page (styles in global.css under "small reactions"), kept quiet: a click ripple, labels
// that decode once, headings whose words rise in, count-ups, the hero leaning away on scroll, the install log completing.
// None of them is needed to read or use the page; with reduced motion only the ones that don't move stay (count-ups
// show their number at once).
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const fine = matchMedia('(pointer: fine)').matches;
/** A soft portal light under the pointer. */
function cursorLight() {
if (reduce || !fine) return;
const light = document.createElement('div');
light.className = 'cursor-light';
light.setAttribute('aria-hidden', 'true');
document.body.append(light);
let x = innerWidth / 2, y = innerHeight / 2, tx = x, ty = y, raf = 0;
const step = () => {
x += (tx - x) * 0.18; y += (ty - y) * 0.18;
light.style.transform = `translate(${x}px, ${y}px)`;
if (Math.abs(tx - x) + Math.abs(ty - y) > 0.5) raf = requestAnimationFrame(step); else raf = 0;
};
addEventListener('pointermove', (e) => {
tx = e.clientX; ty = e.clientY;
light.classList.add('on');
if (!raf) raf = requestAnimationFrame(step);
}, { passive: true });
document.addEventListener('pointerleave', () => light.classList.remove('on'));
}
/** Clicking empty space: the app's Play ripple, a blue ring then an orange one. Mouse (and pen) only: on a touch
* screen every tap or scroll start would draw it. */
@@ -44,7 +24,7 @@ function clickRipples() {
});
}
/** Headings: the portal's colours light the letters under the pointer; words rise in when the heading appears. */
/** Headings: the words rise in when the heading appears. */
function headings() {
document.querySelectorAll<HTMLElement>('main h2, .prose h1').forEach((h) => {
if (!reduce && !h.dataset.split) {
@@ -70,18 +50,10 @@ function headings() {
wrap(h);
h.classList.add('words');
}
if (!fine) return;
h.addEventListener('pointermove', (e) => {
const r = h.getBoundingClientRect();
h.style.setProperty('--hx', `${e.clientX - r.left}px`);
h.style.setProperty('--hy', `${e.clientY - r.top}px`);
h.classList.add('lit');
});
h.addEventListener('pointerleave', () => h.classList.remove('lit'));
});
}
/** Section labels unscramble the first time they show; hover scrambles them again. */
/** Section labels decode the first time they show. */
function labels() {
const GLYPHS = '01<>/\\|=+*#%·-_';
const scramble = (el: HTMLElement) => {
@@ -108,7 +80,6 @@ function labels() {
} else if (n.nodeType === Node.ELEMENT_NODE) (n as Element).classList.add('scr');
}
io.observe(l);
l.addEventListener('pointerenter', () => l.querySelectorAll<HTMLElement>('.scr').forEach(scramble));
});
}
@@ -189,7 +160,6 @@ function logScroll() {
}
export function initInteractions() {
cursorLight();
clickRipples();
headings();
labels();
+54 -59
View File
@@ -1,5 +1,6 @@
/* FramePort Portal, for the web. Colour values are the app's (src/frameport/ui/theme.py, theme "portal"). Dark only:
the app has no light theme and neither does its page. */
/* FramePort Portal, for the web. Colour values are the app's (src/frameport/ui/theme.py, theme "portal"), except
--text-3: the app's #717583 is below 4.5:1 for the small print it carries here, so the page uses a lighter step.
Dark only: the app has no light theme and neither does its page. */
@font-face {
font-family: Sora;
@@ -22,7 +23,7 @@
--border-strong: #393d49;
--text: #edeef2;
--text-2: #a7abb7;
--text-3: #717583;
--text-3: #868a96; /* 4.77:1 on --surface-2, 5.15:1 on --surface */
--accent: #ff8a1f;
--accent-soft: #3a2512;
--on-accent: #160c03;
@@ -53,7 +54,7 @@
--tilt: 18deg; /* the logo's portal tilt */
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
--ease-back: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-spring: cubic-bezier(0.34, 1.3, 0.64, 1); /* a light overshoot: small things land with a little give */
--gutter: clamp(16px, 4vw, 40px);
--page: 1180px;
@@ -69,22 +70,12 @@ body {
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
/* film grain: keeps the large dark areas from looking like flat plastic */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 100;
opacity: 0.035;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
}
img, video, svg { display: block; max-width: 100%; }
a { color: var(--accent); text-underline-offset: 3px; }
a:hover { text-decoration-thickness: 2px; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; }
::selection { background: rgba(255, 138, 31, 0.35); color: var(--text); }
::placeholder { color: var(--text-2); opacity: 1; }
h1, h2, h3 { font-family: var(--font-display); margin: 0; text-wrap: balance; }
h2 { font-size: clamp(30px, 4.2vw, 44px); line-height: 1.1; font-weight: 800; letter-spacing: -0.02em; }
@@ -93,13 +84,13 @@ p { margin: 0; text-wrap: pretty; }
code, kbd, pre { font-family: var(--font-mono); }
.wrap { width: min(100% - 2 * var(--gutter), var(--page)); margin-inline: auto; }
.section { padding-block: clamp(56px, 7vw, 96px); position: relative; }
.section { padding-block: clamp(48px, 6vw, 80px); position: relative; }
.lead { color: var(--text-2); font-size: clamp(16px, 1.6vw, 18px); line-height: 1.6; max-width: 62ch; }
.muted { color: var(--text-2); }
/* section label: "02 · The app" + the portal hairline, the app's section label for the web */
.label {
display: flex; align-items: center; gap: 14px; margin-bottom: 20px;
display: flex; align-items: center; gap: 14px; margin-bottom: 12px;
font: 600 12px/1 var(--font-mono); letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-2);
}
.label b { color: var(--text); font-weight: 600; }
@@ -124,7 +115,7 @@ code, kbd, pre { font-family: var(--font-mono); }
font: 600 15px/20px var(--font-sans); text-decoration: none; cursor: pointer; white-space: nowrap;
color: var(--text); background: transparent;
transition: background-color 220ms var(--ease-out), border-color 220ms var(--ease-out), filter 220ms var(--ease-out),
transform 320ms var(--ease-back);
transform 320ms var(--ease-spring);
}
.btn:active { transform: scale(0.97); }
.btn-primary { background: var(--accent); color: var(--on-accent); }
@@ -153,9 +144,14 @@ code, kbd, pre { font-family: var(--font-mono); }
.cmd .prompt { color: var(--text-3); user-select: none; margin-right: 8px; }
.cmd .ph { color: var(--secondary); }
/* scroll reveal (script in Layout); without JS or with reduced motion, everything is simply shown */
.js .reveal { opacity: 0; transform: translateY(18px); transition: opacity 700ms var(--ease-out), transform 700ms var(--ease-out); }
.js .reveal.in { opacity: 1; transform: none; }
/* a disclosure: one quiet button that opens more of the same thing (the app's screenshots, the full comparison) */
.disclose > summary { list-style: none; display: inline-flex; align-items: center; gap: 10px; padding: 10px 16px;
border-radius: var(--radius-sm); border: 1px solid var(--secondary-line); cursor: pointer; font: 600 14px/20px var(--font-sans);
color: var(--text); transition: background-color 200ms var(--ease-out); }
.disclose > summary::-webkit-details-marker { display: none; }
.disclose > summary:hover { background: rgba(58, 168, 255, .08); }
.disclose > summary i { font-style: normal; color: var(--secondary); transition: transform 300ms var(--ease-out); }
.disclose[open] > summary i { transform: rotate(180deg); }
.visually-hidden { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0);
white-space: nowrap; }
@@ -164,7 +160,6 @@ code, kbd, pre { font-family: var(--font-mono); }
html { scroll-behavior: auto; }
*, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important; }
.js .reveal { opacity: 1; transform: none; }
}
/* documentation pages (rendered from docs/*.md) */
@@ -193,7 +188,7 @@ code, kbd, pre { font-family: var(--font-mono); }
background: var(--surface); color: var(--text-2); font: 600 12px/1.4 var(--font-sans); cursor: pointer; opacity: 0; transition: opacity 150ms; }
.prose pre:hover .copy, .prose pre .copy:focus-visible { opacity: 1; }
.prose pre .copy:hover { color: var(--text); border-color: var(--accent); }
.prose blockquote { padding: 12px 16px; border-left: 3px solid var(--secondary); background: rgba(58, 168, 255, .06); border-radius: 0 var(--radius-sm) var(--radius-sm) 0; color: var(--text-2); }
.prose blockquote { padding: 12px 16px; border: 1px solid rgba(58, 168, 255, .3); background: rgba(58, 168, 255, .06); border-radius: var(--radius-sm); color: var(--text-2); }
.prose blockquote p:last-child { margin: 0; }
.prose img { max-width: 100%; height: auto; border-radius: var(--radius-sm); border: 1px solid var(--border); }
.prose hr { border: 0; border-top: 1px solid var(--border); margin: 32px 0; }
@@ -205,36 +200,33 @@ code, kbd, pre { font-family: var(--font-mono); }
.prose details { margin: 0 0 16px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); }
.prose summary { cursor: pointer; font-weight: 600; }
/* ---------------- everything reacts (scripts/interactions.ts) ---------------- */
.cursor-light { position: fixed; left: 0; top: 0; z-index: 90; width: 420px; height: 420px; margin: -210px 0 0 -210px; border-radius: 50%;
pointer-events: none; opacity: 0; transition: opacity 500ms; mix-blend-mode: screen;
background: radial-gradient(closest-side, rgba(255, 138, 31, .028), rgba(58, 168, 255, .018) 45%, transparent 75%); }
.cursor-light.on { opacity: 1; }
/* ---------------- small reactions (scripts/interactions.ts): quiet, and none of them needed to read the page */
/* sections settle in once: a short rise (scroll reveal script in Layout; without JS everything is simply shown) */
.js .reveal { opacity: 0; transform: translateY(10px); transition: opacity 600ms var(--ease-out), transform 600ms var(--ease-out); }
.js .reveal.in { opacity: 1; transform: none; }
/* clicking empty space: the app's Play ripple, faint */
.click-ring { position: fixed; z-index: 95; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; pointer-events: none;
border: 1px solid var(--secondary); opacity: 0; animation: click-ring .55s var(--ease-out) both; }
.click-ring.o { border-color: var(--accent); }
@media (hover: none) { .click-ring { display: none; } }
@keyframes click-ring { 0% { opacity: .45; transform: scale(1); } 100% { opacity: 0; transform: scale(2.6); } }
@keyframes click-ring { 0% { opacity: .3; transform: scale(1); } 100% { opacity: 0; transform: scale(2.4); } }
/* headings: words rise in, the pointer lights them in the portal's colours */
/* headings: the words rise in, one after another */
.words .w { display: inline-block; }
.js .reveal .words .w, .js .words.pre .w { opacity: 0; transform: translateY(.45em) rotate(2deg); }
.js .reveal.in .words .w { opacity: 1; transform: none; transition: opacity 1150ms var(--ease-out), transform 1300ms var(--ease-back);
transition-delay: calc(var(--wi) * 110ms + 200ms); }
.words.lit { background: radial-gradient(180px circle at var(--hx) var(--hy), var(--accent), var(--secondary) 45%, var(--text) 75%) text;
-webkit-background-clip: text; color: transparent; }
.words.lit .muted { color: transparent; }
.js .reveal .words .w, .js .words.pre .w { opacity: 0; transform: translateY(.3em); }
.js .reveal.in .words .w { opacity: 1; transform: none; transition: opacity 700ms var(--ease-out), transform 800ms var(--ease-out);
transition-delay: calc(var(--wi) * 60ms + 120ms); }
/* section labels */
/* section labels: they decode once (tabular digits keep them still), the portal line draws in */
.label .scr { font-variant-numeric: tabular-nums; }
.label:hover { color: var(--text); cursor: default; }
@supports (animation-timeline: view()) {
.label::after { transform-origin: left; animation: label-line linear both; animation-timeline: view(); animation-range: entry 20% cover 45%; }
@keyframes label-line { from { transform: scaleX(0); } to { transform: scaleX(1); } }
}
/* icons: each moves in its own way when what holds it is pointed at */
.glyph { transition: transform 400ms var(--ease-back); }
/* icons: each moves in its own small way when what holds it is pointed at */
.glyph { transition: transform 400ms var(--ease-spring); }
:is(a, button, li, .stage, .hcard, label):hover > .glyph-sync, :is(a, button, li, .stage):hover .g > .glyph-sync { animation: g-spin .9s var(--ease-out); }
:is(a, button, li, .stage):hover :is(.glyph-frame, .glyph-frame-solid) { animation: g-nod .7s var(--ease-out); }
:is(a, button, li, .stage):hover .glyph-pc { animation: g-flicker .6s steps(2) 2; }
@@ -244,7 +236,7 @@ code, kbd, pre { font-family: var(--font-mono); }
:is(a, button, li, .stage):hover .glyph-test { transform: rotate(14deg) translateY(-1px); }
:is(a, button, li, .stage):hover .glyph-port { transform: translateX(3px); }
:is(a, button, li, .stage):hover .glyph-recipe { transform: rotateY(180deg); }
:is(a, button, li, .stage):hover .glyph-container { animation: g-pop .5s var(--ease-back); }
:is(a, button, li, .stage):hover .glyph-container { animation: g-pop .5s var(--ease-spring); }
:is(a, button, li, .stage):hover .glyph-shot { animation: g-flash .5s ease-out; }
:is(a, button, li, .stage):hover .glyph-link { transform: rotate(-20deg) scale(1.1); }
@keyframes g-spin { to { transform: rotate(360deg); } }
@@ -255,32 +247,35 @@ code, kbd, pre { font-family: var(--font-mono); }
@keyframes g-pop { 40% { transform: scale(1.3) rotate(-6deg); } }
@keyframes g-flash { 30% { filter: drop-shadow(0 0 6px currentColor) brightness(1.8); } }
/* links in running text: the underline wipes in, blue to orange */
/* links in running text: the portal underline; it thickens under the pointer */
:is(.lead, .muted, .small, .small-p, .src, .cl, .cu, .foot, .first, .notice-band, .prose) a:not(.btn):not(.anchor) {
text-decoration: none; background: linear-gradient(90deg, var(--secondary), var(--accent)) left bottom / 100% 1px no-repeat;
transition: background-size 350ms var(--ease-out), color 200ms; padding-bottom: 1px; }
:is(.lead, .muted, .small, .small-p, .src, .cl, .cu, .foot, .first, .notice-band, .prose) a:not(.btn):not(.anchor):hover { background-size: 100% 100%; color: var(--on-accent); border-radius: 3px; }
transition: background-size 200ms var(--ease-out); padding-bottom: 1px; }
:is(.lead, .muted, .small, .small-p, .src, .cl, .cu, .foot, .first, .notice-band, .prose) a:not(.btn):not(.anchor):hover { background-size: 100% 2px; }
/* the hero flies toward its portal as you scroll away */
.hero .copy { transform: translateY(calc(var(--sp, 0) * -60px)); opacity: calc(1 - var(--sp, 0) * 1.1); }
.hero .scene { transform: scale(calc(1 + var(--sp, 0) * .35)) translateY(calc(var(--sp, 0) * 40px)); transform-origin: 50% 45%;
filter: blur(calc(var(--sp, 0) * 3px)); opacity: calc(1 - var(--sp, 0) * .7); }
/* the hero leans back toward its portal as you scroll away (gently) */
.hero .copy { transform: translateY(calc(var(--sp, 0) * -30px)); opacity: calc(1 - var(--sp, 0) * .9); }
.hero .scene { transform: scale(calc(1 + var(--sp, 0) * .15)) translateY(calc(var(--sp, 0) * 20px)); transform-origin: 50% 45%;
filter: blur(calc(var(--sp, 0) * 1.5px)); opacity: calc(1 - var(--sp, 0) * .5); }
@media (prefers-reduced-motion: reduce) {
.words .w { opacity: 1 !important; transform: none !important; }
.hero .copy, .hero .scene { transform: none; opacity: 1; filter: none; }
}
.step .num { display: inline-block; transition: transform 500ms var(--ease-back), filter 300ms; }
.step:hover .num { transform: scale(1.15) rotate(-8deg); filter: drop-shadow(0 6px 18px rgba(255, 138, 31, .35)); }
.notice-band .mark { transition: transform 400ms var(--ease-back); }
.step .num { display: inline-block; transition: transform 500ms var(--ease-spring), filter 300ms; }
.step:hover .num { transform: scale(1.1) rotate(-6deg); filter: drop-shadow(0 6px 18px rgba(255, 138, 31, .3)); }
.notice-band .mark { transition: transform 400ms var(--ease-spring); }
.notice-band:hover .mark { animation: wiggle .6s ease-in-out; }
@keyframes wiggle { 25% { transform: rotate(-14deg) scale(1.1); } 50% { transform: rotate(10deg); } 75% { transform: rotate(-6deg); } }
.wordmark svg { transition: transform 700ms var(--ease-back); }
@keyframes wiggle { 25% { transform: rotate(-10deg) scale(1.06); } 50% { transform: rotate(7deg); } 75% { transform: rotate(-4deg); } }
.wordmark svg { transition: transform 700ms var(--ease-spring); }
.wordmark:hover svg, a:hover > .wordmark svg { transform: rotate(180deg); }
:is(.chip, .tab, .with button, .cf button, .seg button):active { transform: scale(.94); }
:is(.chip, .tab, .with button, .cf button, .seg button):active { transform: scale(.95); }
.built li { transition: background-color 250ms, padding-left 300ms var(--ease-out); border-radius: 4px; }
.built li:hover { background: linear-gradient(90deg, rgba(58, 168, 255, .08), transparent); padding-left: 10px; }
@media (prefers-reduced-motion: reduce) { .step:hover .num, .wordmark:hover svg, a:hover > .wordmark svg { transform: none; } .notice-band:hover .mark { animation: none; } }
@media (prefers-reduced-motion: reduce) {
.js .reveal { opacity: 1; transform: none; }
.words .w { opacity: 1 !important; transform: none !important; }
.hero .copy, .hero .scene { transform: none; opacity: 1; filter: none; }
.step:hover .num, .wordmark:hover svg, a:hover > .wordmark svg { transform: none; }
.notice-band:hover .mark { animation: none; }
}
/* ---------------------------------------------------------------- docs: what scripts/docs-ui.ts adds to the pages */
.prose .term { border-bottom: 1px dotted var(--secondary-line); cursor: help; transition: color 160ms, border-color 160ms; outline: none; }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 229 KiB

+7 -1
View File
@@ -179,13 +179,19 @@ class History:
self.series.clear()
def main_game(sample: dict | None) -> dict | None:
"""The game in front: the agent lists games newest first with SteamVR overlay apps (they run next to games, e.g.
a desktop overlay) last; an overlay alone isn't a game being played. Older agents: the first non-overlay."""
return next((g for g in (sample or {}).get("games") or [] if not g.get("overlay")), None)
def series_of(sample: dict) -> dict[str, float | None]:
"""The values one sample adds to the History."""
mem = sample.get("mem") or {}
total = mem.get("total") or 0
temps = sample.get("temps") or {}
bat = sample.get("battery") or {}
game = (sample.get("games") or [None])[0] or {}
game = main_game(sample) or {}
extra = {f"temp:{g}": v for g, v in temps.items()}
if sample.get("fan") is not None:
extra["fan"] = sample["fan"]
+19 -39
View File
@@ -124,7 +124,6 @@
"All at their defaults": "",
"All games": "",
"All of FramePort's data on this PC is gone": "",
"All of Steam's headset screenshots, newest first.": "",
"All releases": "",
"All {n} {name} processes ended.": "",
"Allow": "",
@@ -136,7 +135,7 @@
"Allows very large pictures (8K video, theaters) instead of quitting.": "",
"Already patched: don't change the game's files": "",
"Already patched: install unchanged": "",
"Already set up": "",
"Already set up?": "",
"Also delete its saves and everything it stored on the Frame (mods, downloaded content)": "",
"Also delete this game's files on this PC ({size})": "",
"Also in your library: {value} version": "",
@@ -188,7 +187,6 @@
"BlazeRush 1.0.349 only (Klownicle's guide, GitHub #57). Its menus wait for Meta's avatar, which never comes on the Frame, before they create the game's own controllers: the second menu ignored all input. Two instructions in libtargemapp.so skip that wait and the avatar mesh drawing; the game's shader cache gets a new folder name (cache/br_shader_v1), so programs compiled before the GL shim's shader fixes aren't reused. Only this exact build is changed (code fingerprint and every instruction checked); anything else is left as it is.": "",
"BlazeRush: controllers without Meta avatars, own shader cache": "",
"Blit quads flagged XrCompositionLayerImageLayoutFB VERTICAL_FLIP upside down (Vulkan). Fixes UI panels and text that show upside down (for example Assassin's Creed Nexus).": "",
"Browse each installed game's files and download your saves.": "",
"Browse this game's files.": "",
"Build page": "",
"Built for PCs (x86_64): runs through a translator, slower": "",
@@ -196,6 +194,7 @@
"Cable connected.": "",
"Camera": "",
"Can't add {name} — drop an APK, a Linux app, a Windows program (.exe) or a folder": "",
"can't hold games": "",
"can't hold games: {reason}": "",
"Can't reach the Frame. Wake it or turn it on; uploads resume by themselves.": "",
"Can't reach your Frame. Make sure it's on, awake and on the same network, then try again.": "",
@@ -253,6 +252,7 @@
"Command-line arguments for the game program. Games that support several VR runtimes pick one with an argument, for example Unreal's -hmd=OpenXR or -hmd=SteamVR, Unity's -vrmode OpenVR, or a game's own switch (-Runtime=OpenXRHMD). FramePort fills them in from the game files (the repack's VD.bat with the Oculus module swapped for OpenXR, else the engine's usual switch).": "",
"Computes some of the game's effects more precisely, so they don't draw streaks or flashes (for example BlazeRush's particles). Comes from a game's recipe.": "",
"Connect": "",
"Connect by address…": "",
"Connect Frames in Developer Mode automatically": "",
"Connect the Frame's USB-C port to this PC with a data cable (not a charge-only one).": "",
"Connect to your Steam Frame to browse its videos, downloads and each game's storage.": "",
@@ -318,7 +318,6 @@
"CPU": "",
"CPU cores": "",
"CPU {cpu} · GPU {gpu} · memory {mem} · {n} processes": "",
"CPU, GPU, memory, temperatures, power and battery.": "",
"Create": "",
"Customize": "",
"Cylinder layers (curved menus and movie screens, for example 4XVR), which the Frame's runtime lacks, are shown as a few flat strips along the curve. 0 = drop them. 360° (equirect) layers can't be shown on the Frame.": "",
@@ -355,7 +354,6 @@
"Developer Mode lets FramePort find and connect to the Frame; the setup turns it on for you. With it on, you can also open Settings → Developer → Pair new host on the Frame and approve FramePort there.": "",
"Diagnostics for frame.gl_multiview_fbo: checks for GL errors after every single-view draw of a multiview shader and logs counters every 5 seconds (tag GLMV in launch.log).": "",
"Diagnostics: the Vulkan shim (frame.vk_sanitize) adds Khronos' validation layer to the game's instance; its findings go to launch.log. The layer library (libVkLayer_khronos_validation.so) must be in the APK.": "",
"Didn't finish ({value}): {error}": "",
"Disable application space warp if used": "",
"Disable controller tracking offset": "",
"Disable Meta XR Audio telemetry": "",
@@ -371,7 +369,6 @@
"Download all ({n})…": "",
"Download all…": "",
"Download and install": "",
"Download one, a selection or all of them.": "",
"Download selected…": "",
"Download to this PC…": "",
"Download to which folder on this PC?": "",
@@ -406,7 +403,6 @@
"Drop to upload": "",
"Drops Quest-only shared library requirements.": "",
"Each game start used to use up one kernel key; after about 200 starts no game started. FramePort stops that, but used keys only come back after a restart of the Frame.": "",
"Each shot is labeled with the game you were playing.": "",
"Enabled by you.": "",
"End all": "",
"End all {n} {name}?": "",
@@ -418,7 +414,6 @@
"End {title}?": "",
"Ending this stops Steam, SteamVR or the desktop.": "",
"End…": "",
"Enter the address": "",
"Enter the Frame's address (for example steamos@frame.local or its IP)": "",
"Environment variables exported by the launcher (for example VK_INSTANCE_LAYERS=\"\" to test without Valve's layers).": "",
"Environment variables for the Proton launcher on the Frame (for example DXVK_HUD=fps), one KEY=value per line.": "",
@@ -444,14 +439,12 @@
"Eye-tracked foveation (Valve)": "",
"failed": "",
"Failed": "",
"Failed: {error}": "",
"Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality games that build their level from the room (for example Demeter).": "",
"Fan": "",
"file": "",
"Files": "",
"Files and settings on the Frame": "",
"Files and settings placed next to the game on the Frame.": "",
"Files and whole folders": "",
"Files on the Frame — {title}": "",
"Filled tags are your own. Outlined tags come from the engine, VR API and store genres. Filter the library by either.": "",
"Filter files (for example .pak, Binaries)": "",
@@ -460,7 +453,6 @@
"Find artwork…": "",
"Find automatically": "",
"firewalld is active. Allow the setup port until the next restart with: sudo firewall-cmd --add-port={port}/tcp": "",
"First-time setup": "",
"Fix crash when the game prepares the microphone": "",
"Fix depth flicker (Unreal Vulkan)": "",
"Fix freezes when controllers vibrate": "",
@@ -525,7 +517,6 @@
"Frame battery: not charging": "",
"Frame not connected": "",
"Frame OpenXR compatibility layer": "",
"Frame rate": "",
"FrameBridge OpenXR adapter": "",
"FramePort": "",
"FramePort can't update itself in {value} (no permission) — opening the release page to download it": "",
@@ -562,8 +553,8 @@
"FramePort's original violet": "",
"FramePort's own name for it": "",
"FramePort's tools, library, artwork and your games' signing keys. Back it up: an update needs the same key, or the game must be reinstalled and loses its saves.": "",
"Frames in Developer Mode show up here.": "",
"frames per second": "",
"Frames that are set up show up here by themselves.": "",
"Free up space": "",
"Free up space on the Frame": "",
"Free up space?": "",
@@ -589,7 +580,6 @@
"Game settings…": "",
"game started": "",
"Game storage": "",
"Game storage and saves": "",
"game window opened": "",
"Game's choice": "",
"Game: the running game's processes plus the busiest others. Steam and SteamVR: Steam, SteamVR and the desktop. All: every program. A lock marks programs that would close Steam or SteamVR.": "",
@@ -662,7 +652,6 @@
"Icon": "",
"If the pointer doesn't hit what you aim at, tilt it up or down.": "",
"If Windows asks whether FramePort may use the network, allow it.": "",
"If you know the Frame's address.": "",
"Ignore focus dips up to": "",
"ILMxLAB's Unreal games (for example Vader Immortal) bind their controls through URPOCKeyMapManagerComponent, which for an Oculus headset picks the Quest (\"SantaCruz\") or the Gear VR key set by IsRunningOnSantaCruz. On the Frame it picks Gear VR, which is empty in a Quest build: grip, trigger and touch reach Unreal but never the game's hands (grabbing does nothing). Removes the branch to the Gear VR set in the Oculus case of the key selector both action and axis mappings use. Found by Klownicle (GitHub #49).": "",
"ILMxLAB's Unreal games (for example Vader Immortal) wait on their loading card until UVRUtils::GetQuestShaderPrecompilePercent reaches 100 %. The precompile only starts on a Quest (IsRunningOnSantaCruz); on any other device the function returns 0.0, so the loading card (a portrait with a progress bar) never ends. Makes that non-Quest branch return 1.0. The Quest branch and the game's normal shader compiling stay as they are. Found by Klownicle (GitHub #49).": "",
@@ -726,6 +715,7 @@
"It has issues: share…": "",
"It needs Android {version}; the Frame has Android 11. Such apps often crash at start, but not always: a launch test tells.": "",
"it stopped after: {value}": "",
"It stopped at “{value}”. {error} Resume picks up where it stopped.": "",
"it stopped right at the start": "",
"It was removed from the library.": "",
"It will be closed without saving. Continue?": "",
@@ -760,7 +750,8 @@
"Language packs from the game's files": "",
"Last build": "",
"Last launch test's log": "",
"Last launch test: {get} · furthest step: {value}": "",
"Last launch test: passed": "",
"Last launch test: {get} · got as far as: {value}": "",
"Last session · ended {ago}": "",
"late": "",
"Launch log": "",
@@ -807,7 +798,6 @@
"Lets you type into this app's text fields on the Frame (they'd close at once).": "",
"Letters, Enter, Backspace, arrows, Esc and shortcuts reach the headset.": "",
"Library": "",
"Light on the game": "",
"Link": "",
"Linux": "",
"Linux app": "",
@@ -816,7 +806,6 @@
"Linux VR app · OpenXR": "",
"Live": "",
"Live view": "",
"Load and temperatures": "",
"Loading…": "",
"Loads FramePort's OpenXR layer under Proton on the Frame. The Frame's SteamVR runtime only accepts OpenXR 1.0 apps, but Proton's VR helper asks for 1.1, so without the layer VR never starts (the game shows as a flat window, or Revive fails with 'Unable to load LibOVRRT DLL'); the layer retries as 1.0. It also emulates xrConvertTimespecTimeToTimeKHR if a runtime refuses it. Ignored on this PC.": "",
"Loads FramePort's shader-fix Vulkan layer into an OpenGL ES game. On the Frame, OpenGL ES runs on Zink (Mesa's GL on Vulkan); the layer sits between Zink and the driver and inserts the words of the adapter setting zink_shader_fix into the SPIR-V modules whose size and SHA-256 match (every other module is untouched), for example stores that set loop counters and accumulators a shader reads before writing, which hang the GPU (Vader Immortal's lightspeed jump; found by Klownicle, GitHub #49). Does nothing without zink_shader_fix (or zink_shader_dump) in the recipe. The layer adds itself to Android's Vulkan debug layer list for the game's process only.": "",
@@ -857,7 +846,6 @@
"Mark application to allow backup": "",
"Marked \"Can't run\": ": "",
"Marks the game as a VR app so headsets show it as one.": "",
"Matched to your games": "",
"Maximum": "",
"Maximum size in pixels of each face of the cube the 360° image is converted to (256–2730). Higher is sharper but uses more GPU memory.": "",
"Memory": "",
@@ -899,7 +887,6 @@
"never": "",
"New folder": "",
"New folder…": "",
"New Frame? Run the setup on it and click Allow here. Already set up? Your Frame shows under step 2.": "",
"New games go to {label}": "",
"New name": "",
"New recipe applied. Click Update on Frame to install it.": "",
@@ -913,7 +900,7 @@
"No folders to rescan yet — add games with Scan a folder…": "",
"No Frame connected: the game goes to your library now and installs when the Frame connects.": "",
"No FramePort game is running. Start one, or switch the filter to see everything.": "",
"No Frames found. New Frame? Use step 1. Otherwise turn on Developer Mode and keep the Frame on this network.": "",
"No Frames found. New Frame? Use Start setup above. Otherwise turn on Developer Mode and keep the Frame on this network.": "",
"No games found in that folder": "",
"No keyboard? Open {page} in Chromium on the Frame, copy the line and paste it into Konsole. This PC shows there as \"{words}\".": "",
"No limit": "",
@@ -1007,7 +994,7 @@
"OpenXR runtime": "",
"or": "",
"Or paste text to type it in one go": "",
"Or turn on Developer Mode on the Frame (Settings → System) and connect it under step 2.": "",
"Or turn on Developer Mode on the Frame (Settings → System) and connect it under \"Already set up?\".": "",
"Or use:": "",
"Orange for actions, blue for the PC side: the logo's two portals": "",
"Other": "",
@@ -1116,6 +1103,7 @@
"Quest ✓": "",
"Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so PC VR games can run on the Frame.": "",
"Quest ✗": "",
"Quest/Android": "",
"Queued": "",
"Queued {len} installs — they run one at a time": "",
"Quiet Meta's VrApi loader (OpenXR games)": "",
@@ -1231,6 +1219,7 @@
"Rotate panels 180°": "",
"Routes OVRPlugin through FramePort's extension shim, which turns Meta's amplitude-envelope vibrations into plain ones: OVRPort's loader reads their nanosecond duration as seconds and allocates gigabytes, freezing the Frame when the game vibrates a controller (for example Lucky's Tale's save slots, GitHub #9). Needs a rebuild.": "",
"Run a launch test after installing on the Frame": "",
"Run one line on the Frame, then click Allow here. You do this once. No password, and nothing changes on the Frame until you allow it.": "",
"Running": "",
"Running a Windows program under Proton": "",
"running for {t}": "",
@@ -1244,7 +1233,6 @@
"Save": "",
"Save as known-good recipe": "",
"Save the screenshots to which folder on this PC?": "",
"Save them to this PC": "",
"Saved as a known-good recipe": "",
"Saved as known-good. Check the GitHub issue that opened and submit it.": "",
"Saved {name} (in {parent})": "",
@@ -1280,8 +1268,8 @@
"Select games to install": "",
"Set by you.": "",
"Set up for FramePort": "",
"Set up the Frame": "",
"Set up with a USB cable": "",
"Set up your Frame": "",
"Sets Unity QualitySettings antiAliasing to 0. Unity's multisampled render-to-texture path can hang the Frame's GL driver ('zink: DEVICE LOST', for example Sniper Elite VR). Try it for GLES Unity games that freeze or crash the GPU.": "",
"Settings": "",
"Settings files for the game": "",
@@ -1349,7 +1337,6 @@
"Something not working? Attach a diagnostics zip to a GitHub issue; personal data is removed. For one game, use its menu instead.": "",
"Something went wrong": "",
"Sort": "",
"Sorted by day": "",
"Sound off": "",
"Sound on": "",
"Source": "",
@@ -1391,6 +1378,7 @@
"Steam Frame": "",
"Steam Frame patches": "",
"Steam Frame: installed on the Frame, with an entry in its Steam library. This PC: a Steam shortcut that starts the game through Revive, for a headset connected to this PC.": "",
"Steam game": "",
"Steam on the Frame downloads Proton (about 1 GiB). Steam restarts once, which closes a running game.": "",
"Steam on the Frame isn't responding. Restart the Frame and try again.": "",
"SteamOS · Developer Mode": "",
@@ -1481,21 +1469,18 @@
"The Frame's battery is at {pct}% and not charging. Plug it into a strong charger — installs pause at {pause}%.": "",
"The Frame's battery is at {pct}%, so installs are paused. Plug it in and they continue.": "",
"The Frame's desktop password, needed only once so FramePort can add its key.": "",
"The Frame's own video encoder does the work where it can.": "",
"The Frame's runtime has no cube-map layers and refuses their swapchains; OVRPlugin then crashes when it submits the next frame (for example Budget Cuts Ultimate). FrameBridge serves such a swapchain itself (a GL cube map, GLES games) and drops its layers, so the game runs on without that layer.": "",
"The Frame's Steam couldn't start {title} (error {code}). Please report it.": "",
"The Frame's Steam didn't accept {title}, so it was added as \"Devkit Game: …\". Starting it now — put the headset on.": "",
"The Frame's Steam library still shows the old artwork.": "",
"The Frame's Steam library still shows the old name.": "",
"the game failed its checks (see the list above)": "",
"The game's audio streams along with the picture.": "",
"The headset is asleep: put it on, then take the screenshot.": "",
"The headset's view and sound open in an mpv window. Open it in your browser any time.": "",
"The headset's view and sound open in your web browser. Stop the stream when you're done.": "",
"The headset's view and sound play right here. Open it in your browser any time.": "",
"The headset's view is open in an mpv window. You can also watch it in your browser.": "",
"The headset's view is open in your browser. Closed the tab? Open it again.": "",
"The headset's view on this PC, for watching along or recording.": "",
"the installed game (APK and its data)": "",
"The issue's text is also on your clipboard. If the form's fields are empty, open a plain issue instead: it comes filled in.": "",
"The launch settings changed since the install — update the Steam shortcut.": "",
@@ -1509,7 +1494,6 @@
"the previous version, kept as a backup": "",
"The processor types the game has code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games can't run on it.": "",
"The recipe or your game files changed since the install. Update to install the new build; saves are kept.": "",
"The running game's frames per second, as it plays.": "",
"The setup turns on Developer Mode, lets FramePort in and installs Lepton if needed. The Frame's desktop closes during it, then FramePort connects by itself. No password needed.": "",
"The store's name": "",
"The time the Frame spends collecting these numbers.": "",
@@ -1530,7 +1514,7 @@
"This deletes the {what} on the Frame. It can't be undone.": "",
"This folder is empty. Upload files with the buttons above.": "",
"This Frame isn't set up for FramePort yet. Go to Steam Frame → Start setup.": "",
"This Frame isn't set up for FramePort yet. Run the setup once (step 1).": "",
"This Frame isn't set up for FramePort yet. Run the setup once.": "",
"This game can't run on the Steam Frame.": "",
"This game is marked \"Can't run\" on the Steam Frame:": "",
"This is a website in an app: it opens in Meta's browser, which the Frame doesn't have. If nothing opens, visit the website in a browser.": "",
@@ -1644,8 +1628,6 @@
"Upload": "",
"Upload files…": "",
"Upload folder…": "",
"Upload from this PC, download, rename and tidy up.": "",
"Upload movies straight into a player's own folder.": "",
"Upload to {label}": "",
"Upload videos into this player's storage on the Frame.": "",
"Upload, download, rename and delete files on the Frame. Videos, Downloads and Documents are shared by every Quest game; each game also has its own storage.": "",
@@ -1678,7 +1660,6 @@
"Video panels that an Android media player draws into (XR_KHR_android_surface_swapchain, for example I Am Monkey's intro video), which the Frame's runtime refuses, get an Android Surface from FrameBridge: each video frame is copied into an ordinary swapchain the panel shows. Without it these panels are missing and a game waiting for its video stays black. 0 = leave them to the runtime.": "",
"Video players' virtual theaters and 360° videos would otherwise stay black.": "",
"Videos": "",
"Videos for your players": "",
"Videos, documents, mods and saves on your Steam Frame": "",
"View": "",
"View screenshot": "",
@@ -1719,13 +1700,11 @@
"What changed in this version and the ones before it.": "",
"What FramePort will do": "",
"What happens? (optional)": "",
"What the player sees": "",
"What the whole Frame draws right now, with the processor's and graphics chip's share. More power means more heat and shorter battery life.": "",
"What your Frame is doing right now": "",
"What your Frame is ready for and what's installed": "",
"What's new in FramePort {version}": "",
"What's new…": "",
"What's running, and a way to end what's stuck.": "",
"Where it's installed": "",
"Where new games go on the Frame, such as a microSD card. Installed games stay put; right-click → Move to… moves one. Cards formatted as FAT, exFAT or NTFS can't hold games.": "",
"Where the power goes": "",
@@ -1744,7 +1723,6 @@
"Windows' firewall blocks the Frame from reaching FramePort. Start the setup again and allow the change when Windows asks.": "",
"Windows' firewall blocks the Frame on public networks. Set this network to Private in Windows' network settings.": "",
"Windows' firewall for WSL blocks the Frame. Show the setup command again and allow the change when Windows asks.": "",
"With sound": "",
"Without a Meta account, OVRPort's platform library makes up a new user id at every start (ovr_GetLoggedInUserID). Games that file saves under the user id (for example Walkabout Mini Golf) then start with an empty profile each time and seem to lose all progress. Gives the game a fixed id instead, different for each game and each FramePort user.": "",
"Working": "",
"Working…": "",
@@ -1779,6 +1757,7 @@
"Your Frame's files, from this PC": "",
"Your own artwork for {title}": "",
"Your own game files aren't touched.": "",
"Your saves on the Frame and each game's signing key are kept.": "",
"{app} opens these links on this PC.": "",
"{count} files · {human}": "",
"{count} on": "",
@@ -1832,7 +1811,7 @@
"",
""
],
"{n} game didn't work out": [
"{n} game ran into a problem": [
"",
""
],
@@ -1894,17 +1873,18 @@
"{title}: checks failed": "",
"{title}: details from {value}": "",
"{title}: done": "",
"{title}: launch test {verdict} · furthest step: {value}": "",
"{title}: name updated in Steam": "",
"{title}: ready": "",
"{title}: Steam artwork updated on the Frame": "",
"{title}: {line}": "",
"{used} of {total}": "",
"{used}/{max} kernel keys used": "",
"{user}@{host} · {get} {get2} (build {get3})": "",
"{value:.0f} MiB": "",
"{value:.1f} GiB": "",
"{value} didn't work out": "",
"{value} didn't finish installing": "",
"{value} failed ({returncode})": "",
"{value} installed, but didn't start": "",
"{value} took too long — update by hand: {join}": "",
"• On the Frame (if selected below): {n} game ({size}), its Steam entry and FramePort's files": [
"",
+38 -30
View File
@@ -9,6 +9,7 @@ The UI only calls `frameport.pipeline`, the targets and the toolchain; long work
from __future__ import annotations
import os
import sys
import threading
import time
import traceback
@@ -20,6 +21,7 @@ import flet as ft
from .. import pipeline
from ..core import applog, library
from ..errors import explain
from ..frame import monitor as M
from ..frame.connection import NOT_IN_LIBRARY, AgentFailed
from ..i18n import fmt_size, tr, tr_n
from ..recommend import catalog
@@ -160,6 +162,8 @@ class FramePortApp:
for key, value in window_geometry(library.setting("ui.window"), T.SCALE).items():
setattr(page.window, key, value)
page.window.on_event = self._on_window_event
if sys.platform == "win32" and (icon := G.window_icon(Path(assets_dir()))):
page.window.icon = str(icon) # source runs: FramePort's icon instead of Flet's
page.on_keyboard_event = self._on_key
if page.web: # the library's right-click menu; otherwise the browser shows its own
try:
@@ -513,7 +517,7 @@ class FramePortApp:
def _on_card_sample(self, s: dict) -> None:
"""A sample for the card (the stream's thread): remember it, keep the fps history, redraw at most once a
second (with the Monitor open the stream runs faster than the card's 5 s)."""
game = (s.get("games") or [None])[0]
game = M.main_game(s)
pkg = game.get("package") if game else None
if pkg != self._card_pkg:
self._card_pkg = pkg
@@ -527,7 +531,7 @@ class FramePortApp:
self._card_art[pkg] = thumbs.url(pkg, CARD_ART, width=160, wait=False) if library.game(pkg) else None
was = self._card_sample
self._card_sample, self._card_state = s, "live"
shown_game = bool(was and was.get("games"))
shown_game = M.main_game(was) is not None
now = time.monotonic()
if now - self._card_shown < 1.0 and shown_game == bool(game):
return
@@ -946,7 +950,11 @@ class FramePortApp:
from .views.library import display_title, twins
g = library.game(pkg)
return display_title(g, twins(library.games())) if g else pkg
if g:
return display_title(g, twins(library.games()))
# a game on the Frame that isn't in this library (Frame page → Installed): the name it has there
on_frame = next((d for d in (self.frame_info or {}).get("installed", []) if d.get("package") == pkg), None)
return (on_frame or {}).get("title") or pkg
def install_options(self, g: dict) -> list[tuple]:
"""[(label, icon, on_click, disabled, tooltip)] — the first is the primary action."""
@@ -1379,9 +1387,10 @@ class FramePortApp:
s = job.summary or {}
fatal = [f for f in s.get("findings", []) if f.get("severity") == "fatal"] or s.get("findings", [])
if job.state == "failed" and pkg in recs:
why = tr("Didn't finish ({value}): {error}").format(value=job.stage or 'install', error=job.error)
why = tr("It stopped at \u201c{value}\u201d. {error} Resume picks up where it stopped.").format(
value=(job.stage or tr("Install")).rstrip("…. "), error=job.error)
elif job.state == "failed":
why = tr("Failed: {error}").format(error=job.error)
why = job.error
else:
why = tr("Installed, but it didn't start: ") + (
fatal[0]["diagnosis"] if fatal
@@ -1408,10 +1417,14 @@ class FramePortApp:
ft.Row(buttons, spacing=T.S2, wrap=True),
], spacing=T.S2)))
n = len(jobs)
failed = [j for j in jobs if j.state == "failed"]
if failed: # what a failed install never touches, said where the user worries about it
rows.append(C.meta(tr("Your saves on the Frame and each game's signing key are kept.")))
one = self._title(jobs[0].package) if jobs[0].package else jobs[0].title
self.page.show_dialog(C.dialog(
tr_n("{n} game didn't work out", "{n} games didn't work out", n) if n > 1 else
tr("{value} didn't work out").format(
value=self._title(jobs[0].package) if jobs[0].package else jobs[0].title),
tr_n("{n} game ran into a problem", "{n} games ran into problems", n) if n > 1 else
tr("{value} didn't finish installing").format(value=one) if failed else
tr("{value} installed, but didn't start").format(value=one),
ft.Column(rows, spacing=T.S3, scroll=ft.ScrollMode.AUTO, tight=True),
height=T.px(min(160 * n + 20, 520)),
actions=[C.ghost(tr("Details"), on_click=lambda e: (self.page.pop_dialog(), self.show_activity(True))),
@@ -1580,9 +1593,8 @@ class FramePortApp:
res, _ = target.launch_test(pkg, job.reporter)
summary = {"verdict": res.verdict, "milestone": res.milestone, "suggestions": []}
job.summary, job.to, job.log_path = summary, to, summary.get("log_path")
verdict, step = C.test_result(summary["verdict"], summary.get("milestone"))
return tr("{title}: launch test {verdict} · furthest step: {value}").format(
title=title, verdict=verdict, value=step)
line = C.test_line(summary["verdict"], summary.get("milestone"))
return tr("{title}: {line}").format(title=title, line=line)
return self.submit(tr("Launch test: {title}").format(title=title), run, pkg, "test", to=to)
def update_steam_art(self, pkg: str) -> Job:
@@ -1671,9 +1683,9 @@ class FramePortApp:
delete_local, extra = self._delete_local_option(pkg)
delete_data = None
if to == "frame": # GitHub #130: what the game stored (saves, mods, downloads) used to survive every uninstall
delete_data = ft.Checkbox(label=tr("Also delete its saves and everything it stored on the Frame (mods, "
"downloaded content)"), value=False, active_color=T.ERROR)
extra = delete_data if extra is None else ft.Column([delete_data, extra], spacing=T.px(8), tight=True)
delete_data, row = C.checkbox(tr("Also delete its saves and everything it stored on the Frame (mods, "
"downloaded content)"), value=False, active_color=T.ERROR)
extra = row if extra is None else ft.Column([row, extra], spacing=T.px(8), tight=True)
def run(job: Job):
self._target_for(to).uninstall(pkg, keep_data=not (delete_data and delete_data.value))
@@ -1696,12 +1708,12 @@ class FramePortApp:
return None, None
size = sum((sum(f.stat().st_size for f in p.rglob("*") if f.is_file()) if p.is_dir() else p.stat().st_size)
for p in files)
box = ft.Checkbox(label=tr("Also delete this game's files on this PC ({size})").format(size=fmt_size(size)),
value=False, active_color=T.ERROR)
box, row = C.checkbox(tr("Also delete this game's files on this PC ({size})").format(size=fmt_size(size)),
value=False, active_color=T.ERROR)
from ..core import winhost
shown = [winhost.to_windows(p) if winhost.is_wsl() else str(p) for p in files]
return box, ft.Column([box, *[C.body("• " + s, T.TEXT_3) for s in shown[:4]]], spacing=T.px(4), tight=True)
return box, ft.Column([row, *[C.body("• " + s, T.TEXT_3) for s in shown[:4]]], spacing=T.px(4), tight=True)
def remove_from_library(self, pkg: str) -> None:
title = self._title(pkg)
@@ -1904,7 +1916,7 @@ class FramePortApp:
else "works"))
notes = C.field(label=tr("Notes (what you checked, known issues)"), multiline=True, min_lines=2,
max_lines=6, width=T.DIALOG_M)
played = ft.Checkbox(label=tr("I played it in the headset with this recipe"), value=False)
played, played_row = C.checkbox(tr("I played it in the headset with this recipe"), value=False)
send = C.primary(tr("Open GitHub issue"), ft.Icons.OPEN_IN_NEW_ROUNDED, on_click=None)
def sync(e=None):
@@ -1928,11 +1940,8 @@ class FramePortApp:
ft.Column([
C.body(tr("Opens a GitHub issue with this recipe so others get it too. No game files or personal "
"data are sent."), T.TEXT_2),
*([C.body(tr("Last launch test: {get} · furthest step: {value}").format(
get=C.test_result(last.get("verdict"), last.get("milestone"))[0],
value=C.test_result(last.get("verdict"), last.get("milestone"))[1]),
T.TEXT_3)] if last else []),
ft.Row([status, C.help_icon("share_config")]), notes, played,
*([C.body(C.test_line(last.get("verdict"), last.get("milestone")), T.TEXT_3)] if last else []),
ft.Row([status, C.help_icon("share_config")]), notes, played_row,
C.body(tr("Launch tests can't check the picture or controls — only you can."),
T.TEXT_3),
], tight=True, spacing=T.S3),
@@ -2751,12 +2760,11 @@ class FramePortApp:
shown_dir = winhost.to_windows(backup_dir) if winhost.is_wsl() else str(backup_dir)
keys_label = tr("Back up the signing keys ({len}) to {shown_dir} first").format(len=len(pl.keys),
shown_dir=shown_dir)
cb_keys = ft.Checkbox(label=keys_label,
value=bool(pl.keys), active_color=T.ACCENT)
cb_frame = ft.Checkbox(label=tr("Also remove FramePort's games and files from the Frame") +
("" if connected else tr(" (connect the Frame first)")), value=connected,
disabled=not connected, active_color=T.ACCENT)
cb_saves = ft.Checkbox(label=tr("Keep game saves on the Frame"), value=True, active_color=T.ACCENT)
cb_keys, keys_row = C.checkbox(keys_label, value=bool(pl.keys), active_color=T.ACCENT)
cb_frame, frame_row = C.checkbox(tr("Also remove FramePort's games and files from the Frame") +
("" if connected else tr(" (connect the Frame first)")), value=connected,
disabled=not connected, active_color=T.ACCENT)
cb_saves, saves_row = C.checkbox(tr("Keep game saves on the Frame"), value=True, active_color=T.ACCENT)
frame_items = [i for i in pl.items if i.kind == "frame"]
steam_items = [i for i in pl.items if i.kind == "steam"]
other = [i for i in pl.items if i.kind == "pc"]
@@ -2792,7 +2800,7 @@ class FramePortApp:
self.page.show_dialog(C.dialog(
tr("Uninstall FramePort?"),
ft.Column([C.body(tr("This removes:"), T.TEXT), *lines, ft.Container(height=T.S2),
cb_keys, cb_frame, cb_saves,
keys_row, frame_row, saves_row,
C.meta(tr("Keep the signing keys: without them, updating a game "
"loses its saves."))],
spacing=T.S2, tight=True, scroll=ft.ScrollMode.AUTO),
+150 -14
View File
@@ -1,6 +1,7 @@
"""Reusable GUI building blocks (all styling comes from ui.theme)."""
from __future__ import annotations
import re
from collections.abc import Callable
from types import SimpleNamespace
from typing import NamedTuple
@@ -77,6 +78,32 @@ def test_result(verdict: str | None, milestone: str | None) -> tuple[str, str]:
return result, steps.get(milestone or "", milestone or tr("nothing yet"))
def test_line(verdict: str | None, milestone: str | None) -> str:
"""The last launch test in one line: how far it got only when it didn't pass (a passed test reached the end)."""
result, step = test_result(verdict, milestone)
if verdict == "pass":
return tr("Last launch test: passed")
return tr("Last launch test: {get} · got as far as: {value}").format(get=result, value=step)
def technical() -> bool:
"""The "Show technical details" switch (game page → Customize; remembered for every screen): engine and VR API
names, runtime names, kernel keys, file systems and the internals in patch titles show only with it on."""
from ..core import library
return bool(library.setting("ui.patch_details", False))
# a bracket that holds internals: an arrow, an underscore, camelCase or an all-caps code ("(INFO → LAUNCHER)",
# "(ovrMessageType_ToString)", "(OVRManager)"); plain ones stay ("(Frame)", "(skip the 2D launcher)")
_TECH_BRACKET = re.compile(r"\s*\((?=[^)]*(?:→|_|[a-z][A-Z]|[A-Z]{4,}))[^)]*\)")
def patch_title(title: str, details: bool | None = None) -> str:
"""A patch's title as shown: without bracketed internals unless technical details are on."""
title = tr(title)
return title if (technical() if details is None else details) else _TECH_BRACKET.sub("", title).strip()
def settings_diff(game: dict, frame_info: dict | None) -> tuple[list[str], list[str]] | None:
"""(patches turned on, patches turned off) in the app since the Frame's copy was installed; None if the same (or
unknown: installs from before FramePort recorded their recipe)."""
@@ -213,27 +240,75 @@ def h2(text: str) -> ft.Text:
return ft.Text(text, size=T.T_H2, weight=ft.FontWeight.W_600, color=T.TEXT)
def checkbox(label: str, **kw) -> tuple[ft.Checkbox, ft.Control]:
"""(the checkbox, the control to show): a checkbox whose label wraps (Material's own label stays on one line and
runs past a dialog's edge); clicking the label toggles it too."""
box = ft.Checkbox(**kw)
def click_label(e):
if box.disabled:
return
box.value = not box.value
update(box)
if box.on_change:
box.on_change(SimpleNamespace(control=box))
return box, ft.Row([box, ft.Container(ft.Text(label, color=T.TEXT, size=T.px(14)), expand=True,
on_click=click_label, ink=True, border_radius=T.RADIUS_XS)],
spacing=T.S1, vertical_alignment=ft.CrossAxisAlignment.CENTER)
SWITCH_TRACK = (52, 32) # Material 3's switch track in logical px (the Switch itself doesn't follow the UI scale)
def paint_switch_track(track: ft.Container, on: bool, disabled: bool = False) -> None:
"""On: the portal (blue into orange, as in the logo) under a dark thumb; off: a quiet well with an edge."""
if on:
track.gradient = portal_gradient(opacity=0.95) if T.DUAL else None
track.bgcolor = None if T.DUAL else T.ACCENT
track.border = None
else:
track.gradient, track.bgcolor = None, T.SURFACE_3
track.border = ft.Border.all(2, T.BORDER_STRONG)
track.opacity = 0.45 if disabled else 1.0
def switch(label: str = "", wrap: bool = True, **kw) -> ft.Control:
"""A labelled switch in the theme's text colour (Material's default label is dark text). The label wraps (a
Switch's own label is cut off when it's longer than the window); clicking it toggles the switch too.
wrap=False keeps the built-in label, for a short label in a Row next to other controls."""
kw.setdefault("active_color", T.ACCENT)
if T.DUAL: # orange thumb on a blue track
kw.setdefault("active_track_color", T.soft(T.SECONDARY, 0.5))
if not wrap or not label:
return ft.Switch(label=label or None, label_text_style=ft.TextStyle(color=T.TEXT, size=T.px(14)), **kw)
sw = ft.Switch(**kw)
wrap=False keeps the label on one line, for a short label in a Row next to other controls.
The track carries the portal gradient when on: the real ft.Switch (keyboard, focus, semantics) sits with a
transparent track on a painted one (paint_switch_track), repainted on every change."""
user_change = kw.pop("on_change", None)
clear = {ft.ControlState.DEFAULT: ft.Colors.TRANSPARENT}
kw.setdefault("track_color", clear)
kw.setdefault("track_outline_color", clear)
# the thumb is the portal's dark middle on the gradient (a light check on it), light grey with a dark cross when
# off; an icon in both states keeps the thumb the same size on and off (Material 3 shrinks an empty off thumb)
kw.setdefault("thumb_color", {ft.ControlState.SELECTED: T.BG, ft.ControlState.DEFAULT: T.TEXT_2})
kw.setdefault("thumb_icon", {ft.ControlState.SELECTED: ft.Icons.CHECK_ROUNDED,
ft.ControlState.DEFAULT: ft.Icons.CLOSE_ROUNDED})
track = ft.Container(width=SWITCH_TRACK[0], height=SWITCH_TRACK[1], border_radius=SWITCH_TRACK[1] / 2)
def changed(e):
paint_switch_track(track, bool(sw.value), bool(sw.disabled))
update(track)
if user_change:
user_change(e)
sw = ft.Switch(on_change=changed, **kw)
paint_switch_track(track, bool(sw.value), bool(sw.disabled))
knob = ft.Stack([track, sw], alignment=ft.Alignment.CENTER)
if not label:
return knob
def click_label(e):
if sw.disabled:
return
sw.value = not sw.value
update(sw)
if sw.on_change:
sw.on_change(SimpleNamespace(control=sw))
return ft.Row([sw, ft.Container(ft.Text(label, color=T.TEXT, size=T.px(14)), expand=True, on_click=click_label,
ink=True, border_radius=T.RADIUS_XS)],
spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER, data="switch")
changed(SimpleNamespace(control=sw))
text = ft.Text(label, color=T.TEXT, size=T.px(14), no_wrap=not wrap)
return ft.Row([knob, ft.Container(text, expand=wrap, on_click=click_label, ink=True, border_radius=T.RADIUS_XS)],
spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER, data="switch", tight=not wrap)
def body(text: str, color: str | None = None, size: int | None = None, **kw) -> ft.Text:
@@ -656,11 +731,72 @@ def portal_glow() -> ft.RadialGradient:
return ft.RadialGradient(colors=colors, stops=stops, center=ft.Alignment(0, -0.25), radius=0.85)
def _ghost_bar(width: float, height: float = 10, color: str | None = None) -> ft.Container:
return ft.Container(width=T.px(width), height=T.px(height), border_radius=T.px(4),
bgcolor=color or T.BORDER_STRONG)
def ghost_preview(kind: str) -> ft.Control:
"""A faint, wordless sketch of what a Frame tab shows once a Frame is connected (its not-connected empty state):
shapes only, so it reads as "this is where it goes", never as content. kinds: files, screenshots, live,
keyboard, monitor."""
if kind == "files":
rows = [ft.Row([as_icon(ft.Icons.FOLDER_OUTLINED if i < 2 else ft.Icons.MOVIE_OUTLINED, T.px(18),
T.BORDER_STRONG),
_ghost_bar(w), ft.Container(expand=True), _ghost_bar(56), _ghost_bar(18, 18)],
spacing=T.S3)
for i, w in enumerate((140, 96, 210, 172, 120))]
return card(ft.Column(rows, spacing=T.S4), bgcolor=T.SURFACE)
if kind == "screenshots":
return ft.Row([ft.Container(width=T.px(240), height=T.px(135), border_radius=T.RADIUS_SM, bgcolor=T.SURFACE_2,
border=ft.Border.all(1, T.BORDER)) for _ in range(6)],
wrap=True, spacing=T.S3, run_spacing=T.S3, alignment=ft.MainAxisAlignment.CENTER)
if kind == "live":
return ft.Container(as_icon(G.LIVE, T.px(40), T.BORDER_STRONG), width=T.px(640), height=T.px(360),
alignment=ft.Alignment.CENTER, border_radius=T.RADIUS, bgcolor=T.BG,
border=ft.Border.all(1, T.BORDER_STRONG))
if kind == "keyboard":
keys = ft.Row([ft.Container(width=T.px(w), height=T.px(34), border_radius=T.px(6), bgcolor=T.SURFACE_2,
border=ft.Border.all(1, T.BORDER)) for w in (34, 34, 34, 34, 34, 34, 34, 120)],
spacing=T.S2, alignment=ft.MainAxisAlignment.CENTER)
return ft.Column([card(ft.Column([_ghost_bar(220), _ghost_bar(160)], spacing=T.S3), bgcolor=T.SURFACE,
height=T.px(130), width=T.px(640)), keys],
spacing=T.S4, horizontal_alignment=ft.CrossAxisAlignment.CENTER)
# monitor: tiles with a little graph, then a few process rows
spark = [5, 9, 7, 12, 10, 14, 11, 16, 13, 15, 12, 17]
tile = card(ft.Column([_ghost_bar(70), _ghost_bar(44, 18),
ft.Row([ft.Container(width=T.px(5), height=T.px(h), border_radius=T.px(2),
bgcolor=T.BORDER_STRONG) for h in spark],
spacing=T.px(3), vertical_alignment=ft.CrossAxisAlignment.END)],
spacing=T.S2), bgcolor=T.SURFACE, width=T.px(180))
rows = [ft.Row([_ghost_bar(w), ft.Container(expand=True), _ghost_bar(40), _ghost_bar(40)], spacing=T.S4)
for w in (180, 130, 220, 150)]
return ft.Column([ft.Row([tile] * 4, spacing=T.S3, wrap=True, alignment=ft.MainAxisAlignment.CENTER),
card(ft.Column(rows, spacing=T.S4), bgcolor=T.SURFACE)], spacing=T.S3)
def empty_state(icon: str, heading: str, text: str, *actions: ft.Control,
features: list[tuple[str, str, str]] | None = None) -> ft.Container:
features: list[tuple[str, str, str]] | None = None, preview: ft.Control | None = None) -> ft.Container:
"""A screen with nothing to show yet: the portal with what the screen is about coming out of it, a heading, one
sentence and the action that fills it. `features` (icon, heading, sentence) say what the screen will show, so a
first look isn't an empty page; with them the portal is bigger and a soft portal light fills the background."""
first look isn't an empty page; with them the portal is bigger and a soft portal light fills the background.
`preview` (C.ghost_preview) is the quieter form for tabs waiting on the Frame: a faint sketch of the tab under
the action, fading out, instead of the feature cards."""
if preview is not None:
sketch = ft.Stack([ft.Container(preview, opacity=0.8),
ft.Container(left=0, right=0, bottom=0, height=T.px(140),
gradient=ft.LinearGradient(begin=ft.Alignment.TOP_CENTER,
end=ft.Alignment.BOTTOM_CENTER,
colors=[T.soft(T.BG, 0.0), T.BG]))])
return ft.Container(ft.Column([
portal_scene(icon),
title(heading, T.T_DISPLAY),
ft.Container(body(text, text_align=ft.TextAlign.CENTER), width=T.px(500)),
ft.Row(list(actions), alignment=ft.MainAxisAlignment.CENTER, spacing=T.S3),
ft.Container(height=T.S4),
ft.Container(sketch, width=T.px(780)),
], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S3, scroll=ft.ScrollMode.AUTO, expand=True),
alignment=ft.Alignment.TOP_CENTER, expand=True, padding=ft.Padding(0, T.S6, 0, 0))
rich = bool(features)
return ft.Container(ft.Column([
portal_scene(icon, T.px(104) if rich else None),
+2 -3
View File
@@ -24,10 +24,9 @@ def now_playing(sample: dict | None, fps_series: list | None = None) -> dict | N
"""Display values for the card's "now playing" row, or None when no game runs: package, title, line ("playing ·
27 min"), fps text ("72" / "–"), level (ok | warn | error by the fps against its presumed refresh rate) and the
fps target (refresh rate) for the sparkline's guide."""
games = (sample or {}).get("games") or []
if not games:
g = M.main_game(sample)
if g is None:
return None
g = games[0]
pkg = g.get("package") or ""
elapsed = g.get("elapsed")
line = tr("playing · {t}").format(t=M.fmt_duration(elapsed)) if elapsed is not None else tr("playing")
+21
View File
@@ -45,6 +45,27 @@ def install(assets: Path) -> Path:
return target
APP_ICON = Path(__file__).resolve().parents[2] / "assets" / "icon.png" # src/assets: the bundles' app icon
def window_icon(assets: Path, source: Path = APP_ICON) -> Path | None:
"""<assets>/ui-icons/app.ico made from the app icon, for the window and taskbar of source runs on Windows (they
run Flet's prebuilt client, which shows Flet's logo; `flet build` bundles have the icon built in). None when the
icon isn't there (bundles) or can't be converted."""
dest = assets / FOLDER / "app.ico"
try:
if dest.is_file() and dest.stat().st_mtime >= source.stat().st_mtime:
return dest
from PIL import Image
dest.parent.mkdir(parents=True, exist_ok=True)
with Image.open(source) as im:
im.convert("RGBA").save(dest, sizes=[(n, n) for n in (16, 20, 24, 32, 40, 48, 64, 128, 256)])
return dest
except Exception: # noqa: BLE001 (Flet's icon then; never worth failing the start)
return None
PORTAL_REV = 2
+3 -3
View File
@@ -16,7 +16,7 @@ import flet as ft
# Colour tokens of a theme:
# surfaces: BG window, SIDEBAR, SURFACE cards, SURFACE_2 raised/hover, SURFACE_3 inputs/chips, BORDER(_STRONG)
# text: TEXT, TEXT_2 secondary, TEXT_3 meta/disabled
# text: TEXT, TEXT_2 secondary, TEXT_3 meta/disabled (built-ins keep all three at >= 4.5:1 on BG..SURFACE_2)
# ACCENT (+ ACCENT_SOFT tint, ON_ACCENT text on it): the main action and the current selection
# SECONDARY (+ SECONDARY_SOFT): secondary actions, progress, switch tracks (with "dual" also the two-colour touches)
# states OK/WARN/ERROR/INFO, PC = PC VR / "on this PC"
@@ -29,7 +29,7 @@ TOKENS = ("BG", "SIDEBAR", "SURFACE", "SURFACE_2", "SURFACE_3", "BORDER", "BORDE
_PORTAL = {
"BG": "#0D0E12", "SIDEBAR": "#111217", "SURFACE": "#16181E", "SURFACE_2": "#1D1F27", "SURFACE_3": "#252832",
"BORDER": "#2A2D37", "BORDER_STRONG": "#393D49",
"TEXT": "#EDEEF2", "TEXT_2": "#A7ABB7", "TEXT_3": "#717583",
"TEXT": "#EDEEF2", "TEXT_2": "#A7ABB7", "TEXT_3": "#868A96",
"ACCENT": "#FF8A1F", "ACCENT_SOFT": "#3A2512", "ON_ACCENT": "#160C03",
"SECONDARY": "#3AA8FF", "SECONDARY_SOFT": "#0F2A40",
# INFO is a cyan of its own: next to PC (the portal blue) it tells data series apart (Monitor: Steam vs
@@ -45,7 +45,7 @@ BUILTIN: dict[str, dict] = {
"original": {"name": "Original", "dual": False, "colors": { # FramePort's colours before the Portal theme
"BG": "#0E0F13", "SIDEBAR": "#121319", "SURFACE": "#171920", "SURFACE_2": "#1E2029", "SURFACE_3": "#262935",
"BORDER": "#2C2F3B", "BORDER_STRONG": "#3A3E4D",
"TEXT": "#ECEDF3", "TEXT_2": "#A9ADBD", "TEXT_3": "#737889",
"TEXT": "#ECEDF3", "TEXT_2": "#A9ADBD", "TEXT_3": "#868B9C",
"ACCENT": "#8B7CFF", "ACCENT_SOFT": "#2A2550", "ON_ACCENT": "#0E0F13",
"SECONDARY": "#38BDF8", "SECONDARY_SOFT": "#12303F",
"OK": "#4ADE80", "WARN": "#FBBF24", "ERROR": "#F87171", "INFO": "#60A5FA", "PC": "#38BDF8"}},
+1 -2
View File
@@ -112,8 +112,7 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None
kind, *spec = UI[key]["control"]
v = values[key]
if kind == "switch":
return ft.Switch(value=bool(v), active_color=T.ACCENT,
on_change=lambda e: set_value(key, 1 if e.control.value else 0))
return C.switch(value=bool(v), on_change=lambda e: set_value(key, 1 if e.control.value else 0))
if kind == "slider":
lo, hi, step, fmt = spec
readouts[key] = C.body(fmt.format(v), T.TEXT, weight=ft.FontWeight.W_600)
+1 -8
View File
@@ -131,14 +131,7 @@ class FilesView:
tr("Connect to your Steam Frame to browse its videos, downloads and each game's "
"storage."),
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")),
features=[
(ft.Icons.VIDEO_LIBRARY_OUTLINED, tr("Videos for your players"),
tr("Upload movies straight into a player's own folder.")),
(ft.Icons.SAVE_ROUNDED, tr("Game storage and saves"),
tr("Browse each installed game's files and download your saves.")),
(ft.Icons.DRIVE_FOLDER_UPLOAD_OUTLINED, tr("Files and whole folders"),
tr("Upload from this PC, download, rename and tidy up.")),
])], expand=True)
preview=C.ghost_preview("files"))], expand=True)
if self.root is None:
self.root = ft.Column([
app.top_bar(tr("Files"), tr("Videos, documents, mods and saves on your Steam Frame")),
+36 -24
View File
@@ -89,7 +89,8 @@ class FrameView:
app = self.app
items = []
lepton = info.get("lepton")
items.append(C.Check(bool(lepton), tr("Quest games (Lepton)"),
details = C.technical()
items.append(C.Check(bool(lepton), tr("Quest games (Lepton)") if details else tr("Quest games"),
tr("Ready") if lepton
else tr("Not installed yet (needs Developer Mode)"),
"lepton", (tr("Install"), ft.Icons.DOWNLOAD_ROUNDED, app.install_lepton)))
@@ -97,7 +98,7 @@ class FrameView:
ready, sug = pr.get("ready"), pr.get("suggested")
if pr:
items.append(C.Check(
bool(ready), tr("PC VR games (Proton)"),
bool(ready), tr("PC VR games (Proton)") if details else tr("PC VR games"),
tr("{display_name} installed").format(display_name=ready['display_name']) if ready else
(tr("{display_name} can be installed (about 1 GiB; Steam restarts once)")
.format(display_name=sug['display_name']) if sug else
@@ -153,13 +154,16 @@ class FrameView:
if d.get("usable"):
opts.append(ft.DropdownOption(key=drives.INTERNAL if d["internal"] else d["install_dir"],
text=" · ".join(filter(None, [name(d), free]))))
details = C.technical()
reason = d.get("reason") or ""
state = tr_n("{n} game", "{n} games", d.get("games", 0)) if d.get("usable") else \
tr("can't hold games: {reason}").format(reason=d.get("reason") or "")
tr("can't hold games: {reason}").format(reason=reason) if details else tr("can't hold games")
lines.append(ft.Row([
ft.Icon(ft.Icons.SD_CARD_ROUNDED if d.get("removable") else ft.Icons.STORAGE_OUTLINED,
size=T.px(18), color=T.TEXT_2 if d.get("usable") else T.TEXT_3),
C.body(name(d), T.TEXT if d.get("usable") else T.TEXT_3, expand=True),
C.meta(" · ".join(filter(None, [d.get("fstype"), free, state]))),
ft.Container(C.meta(" · ".join(filter(None, [d.get("fstype") if details else None, free, state]))),
tooltip=C.tip(reason) if reason and not details else None),
], spacing=T.S2))
if current and not any(o.key == current for o in opts): # the chosen card isn't inserted now
label = current.rstrip("/").rsplit("/", 2)[-2] if current.rstrip("/").endswith("/FramePort") \
@@ -269,8 +273,8 @@ class FrameView:
devkit=f.source == "devkit")),
], spacing=T.S3), padding=ft.Padding(T.S3, T.px(8), T.S2, T.px(8)), bgcolor=T.SURFACE_2,
border_radius=T.RADIUS_SM))
found.controls = items or [ft.Row([C.body(tr("No Frames found. New Frame? Use step 1. Otherwise turn on "
"Developer Mode and keep the Frame on this network."),
found.controls = items or [ft.Row([C.body(tr("No Frames found. New Frame? Use Start setup above. Otherwise "
"turn on Developer Mode and keep the Frame on this network."),
expand=True),
C.help_icon("developer_mode")], spacing=T.px(4))]
found.controls.append(C.ghost(tr("Search again"), ft.Icons.REFRESH_ROUNDED, lambda e: app.run_bg(discover)))
@@ -445,7 +449,7 @@ class FrameView:
weight=ft.FontWeight.W_600),
C.body(hint, T.TEXT),
ft.Row([C.meta(tr("Or turn on Developer Mode on the Frame (Settings → System) and connect it under "
"step 2."),
"\"Already set up?\"."),
expand=True), C.help_icon("developer_mode")], spacing=T.px(4)),
], spacing=T.S2), "warn"))
if update:
@@ -457,11 +461,19 @@ class FrameView:
addr = C.field(hint_text=tr("steamos@frame.local or an IP address"), width=T.px(320))
pw = C.field(hint_text=tr("Password (first time only)"), password=True, can_reveal_password=True,
width=T.px(240), tooltip=C.tip(HELP["password"]))
# by address: for people who know their network; out of the way until asked for (the setup needs no password)
by_address = ft.Row([addr, pw, C.primary(tr("Connect"),
on_click=lambda e: app.connect_manual(addr.value, pw.value))],
wrap=True, spacing=T.S3, visible=False)
def show_address(e):
by_address.visible = not by_address.visible
C.update(by_address)
saved = saved_targets()
offline = None
if getattr(app, "_not_paired", False): # it answered, but doesn't know FramePort yet
offline = C.callout(ft.Row([
C.body(tr("This Frame isn't set up for FramePort yet. Run the setup once (step 1)."), T.TEXT,
C.body(tr("This Frame isn't set up for FramePort yet. Run the setup once."), T.TEXT,
expand=True),
C.primary(tr("Start setup"), ft.Icons.TERMINAL_ROUNDED, pair)]), "warn")
elif saved and app.frame_state == "offline":
@@ -501,16 +513,18 @@ class FrameView:
"info"))
return out
step = lambda n, head, text, *content, help=None: C.card(ft.Row([ # noqa: E731
ft.Container(ft.Text(str(n), weight=ft.FontWeight.W_700, color=T.ACCENT), width=T.px(30), height=T.px(30),
border_radius=T.px(15), bgcolor=T.ACCENT_SOFT, alignment=ft.Alignment.CENTER),
ft.Column([C.with_help(C.h2(head), help), C.body(text), *content], spacing=T.S3, expand=True),
], spacing=T.S4, vertical_alignment=ft.CrossAxisAlignment.START), padding=T.S5)
def block(head, text, *content, help=None, main=False):
"""One way to connect. The setup is the main one (a bigger heading, the portal's light); the others are
quieter cards. No numbers: they are alternatives, not steps."""
heading = C.title(head, T.T_DISPLAY) if main else C.h2(head)
return C.card(ft.Column([C.with_help(heading, help), C.body(text), *content], spacing=T.S3),
padding=T.S6 if main else T.S5, bgcolor=T.SURFACE_2 if main else None)
return ft.Column([
app.top_bar(tr("Connect your Steam Frame"), tr("FramePort installs games on the Frame over your network")),
*([offline] if offline else []),
step(1, tr("First-time setup"), tr("New Frame? Run the setup on it and click Allow here. Already set up? "
"Your Frame shows under step 2."),
block(tr("Set up your Frame"),
tr("Run one line on the Frame, then click Allow here. You do this once. No password, and nothing "
"changes on the Frame until you allow it."),
ft.Row([C.primary(tr("Start setup"), ft.Icons.TERMINAL_ROUNDED, pair),
C.meta(tr("or"), weight=ft.FontWeight.W_600),
C.secondary(tr("Set up with a USB cable"), ft.Icons.USB_ROUNDED, usb_explain,
@@ -521,15 +535,13 @@ class FrameView:
"that block the search."))],
wrap=True, spacing=T.S3, vertical_alignment=ft.CrossAxisAlignment.CENTER),
pair_box,
help="first_time_setup"),
step(2, tr("Already set up"), tr("Frames in Developer Mode show up here."), found,
*auto_pair_controls(),
help="developer_mode"),
step(3, tr("Enter the address"), tr("If you know the Frame's address."),
ft.Row([addr, pw, C.primary(tr("Connect"),
on_click=lambda e: app.connect_manual(addr.value, pw.value))],
wrap=True, spacing=T.S3)),
], spacing=T.S4, scroll=ft.ScrollMode.AUTO, expand=True)
help="first_time_setup", main=True),
block(tr("Already set up?"), tr("Frames that are set up show up here by themselves."), found,
*auto_pair_controls(),
help="developer_mode"),
ft.Column([ft.Row([C.ghost(tr("Connect by address…"), ft.Icons.LAN_ROUNDED, show_address)]), by_address],
spacing=T.S2),
], spacing=T.S4, scroll=ft.ScrollMode.AUTO, expand=True, horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
def build(self) -> ft.Control:
app = self.app
+8 -10
View File
@@ -244,9 +244,7 @@ class GameView:
tr("Needs the Meta Horizon app for its license check, so it crashes on the Frame. Play it on "
"this PC.") if rift_platform else
tr("Uses its own Revive — experimental on the Frame") if rift_repack and not last else
tr("Last launch test: {get} · furthest step: {value}")
.format(get=C.test_result(last.get("verdict"), last.get("milestone"))[0],
value=C.test_result(last.get("verdict"), last.get("milestone"))[1]) if last
C.test_line(last.get("verdict"), last.get("milestone")) if last
else tr("Runs directly — no Revive needed") if self.rift else
tr("Runs directly on SteamOS, from the Frame's Steam library") if self.linux else "")
cards.append(self.target_card(
@@ -635,7 +633,7 @@ class GameView:
else:
lead, icon, color = tr("Suggested by FramePort from the game's engine"), \
ft.Icons.AUTO_AWESOME_ROUNDED, T.ACCENT
chips = [ft.Container(C.body(tr(p.title), T.TEXT, size=T.T_META),
chips = [ft.Container(C.body(C.patch_title(p.title), T.TEXT, size=T.T_META),
tooltip=C.tip(tr(recipe.reasons.get(p.id) or p.description)),
bgcolor=T.SURFACE_3, border_radius=T.RADIUS_XS,
padding=ft.Padding(T.px(10), T.px(5), T.px(10), T.px(5)))
@@ -780,7 +778,7 @@ class GameView:
if reason:
shown = tr(reason).replace("overport", "OVRPort") if technical else \
plain_reason(reason, p.default_on)
sub.insert(0, C.meta(shown, T.ACCENT))
sub.insert(0, C.meta(shown, T.SECONDARY)) # where a patch came from: information, not an action
if on and p.id in superseded:
sub.append(C.meta(upstream_note, T.TEXT_3))
extra = None
@@ -791,7 +789,7 @@ class GameView:
if current not in keys: # a tool name typed under technical details
choices = (*choices, (current, current))
rows.append(ft.Container(ft.Row([
ft.Column([C.body(tr(p.title), T.TEXT, weight=ft.FontWeight.W_500), *sub],
ft.Column([C.body(C.patch_title(p.title, technical), T.TEXT, weight=ft.FontWeight.W_500), *sub],
spacing=T.px(3), expand=True),
C.dropdown(value=current, width=T.px(280),
options=[ft.DropdownOption(key=k, text=tr(label)) for k, label in choices],
@@ -811,13 +809,13 @@ class GameView:
width=T.px(260), multiline=multi, min_lines=1, max_lines=4 if multi else 1,
on_blur=set_param(p.id, q.key))
rows.append(ft.Container(ft.Row([
ft.Column([ft.Row([C.body(tr(p.title), T.TEXT, weight=ft.FontWeight.W_500)]
ft.Column([ft.Row([C.body(C.patch_title(p.title, technical), T.TEXT, weight=ft.FontWeight.W_500)]
+ ([C.pill(tr("experimental"), T.WARN, tooltip=C.tip(HELP["experimental"]))]
if p.experimental else [])
+ ([C.meta(p.id)] if technical else []), spacing=T.S2, wrap=True), *sub],
spacing=T.px(3), expand=True),
*([extra] if extra else []),
ft.Switch(value=on, on_change=toggle(p.id), active_color=T.ACCENT),
C.switch(value=on, on_change=toggle(p.id)),
], spacing=T.S3), padding=ft.Padding(T.S4, T.px(10), T.S4, T.px(10)), border=ft.Border(
top=ft.BorderSide(1, T.BORDER))))
if not rows:
@@ -842,13 +840,13 @@ class GameView:
alt = ", ".join(recipe.alt_patches if technical else
[tr(base.get(p).title) if p in base.REGISTRY else p for p in recipe.alt_patches])
top.append(C.with_help(C.switch(label=tr("Install the second build (") + alt
+ ")", value=recipe.use_alt, on_change=set_alt, active_color=T.ACCENT),
+ ")", value=recipe.use_alt, on_change=set_alt),
"alt_build"))
if hidden:
top.append(C.with_help(C.switch(
label=tr("Show all patches ({len} don't apply to this game)").format(len=len(hidden)),
value=self.show_all,
active_color=T.ACCENT, on_change=lambda e: app.open_game(package, advanced=True,
on_change=lambda e: app.open_game(package, advanced=True,
show_all=e.control.value)), "show_all"))
warn.value = "\n".join(engine.warnings(recipe))
return C.section(tr("Patches"), *top, warn, *sections,
+1 -1
View File
@@ -67,7 +67,7 @@ class KeyboardView:
C.empty_state(G.KEYS, tr("Type in VR with your PC's keyboard"),
tr("Connect to your Steam Frame to type into the headset from here."),
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")),
features=keyboard_tips())], expand=True)
preview=C.ghost_preview("keyboard"))], expand=True)
if self.root is None:
self.root = ft.Column([
app.top_bar(heading, sub),
+1 -1
View File
@@ -102,7 +102,7 @@ def platform_filter_options(games: list[dict]) -> list[tuple[str, str]]:
kinds = {g.get("kind") or "quest" for g in games}
if not kinds & {"rift", "linux"}:
return []
out = [("all", tr("All")), ("quest", tr("Android"))]
out = [("all", tr("All")), ("quest", tr("Quest/Android"))]
if "rift" in kinds:
out.append(("pcvr", tr("PC VR")))
if "linux" in kinds:
+3 -11
View File
@@ -58,15 +58,6 @@ def health_text(st: dict) -> str:
"quality.", st.get("dropped") or 0)
def live_features() -> list[tuple[str, str, str]]:
"""What the live view does (its empty states)."""
return [(G.LIVE, tr("What the player sees"),
tr("The headset's view on this PC, for watching along or recording.")),
(ft.Icons.VOLUME_UP_ROUNDED, tr("With sound"), tr("The game's audio streams along with the picture.")),
(ft.Icons.MEMORY_ROUNDED, tr("Light on the game"),
tr("The Frame's own video encoder does the work where it can."))]
VIDEO_START_S = 12 # the in-window player must show a moving picture within this, else the next player takes over
VIDEO_REOPENS = 3 # times a minute the in-window player reconnects after an unexpected end of the stream
@@ -134,7 +125,7 @@ class LiveView:
C.empty_state(G.LIVE, tr("See what the headset sees"),
tr("Connect to your Steam Frame to stream its view and sound to this PC."),
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")),
features=live_features())], expand=True)
preview=C.ghost_preview("live"))], expand=True)
if self.embedded_ok is None:
self.embedded_ok = LP.embedded_available(web=bool(getattr(app.page, "web", False)))
self.mpv_exe = LP.find_mpv()
@@ -169,7 +160,7 @@ class LiveView:
], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=T.S2, tight=True),
alignment=ft.Alignment.CENTER, expand=True, padding=T.S6, gradient=C.portal_glow(),
bgcolor=T.soft("#000000", 0.25), border=ft.Border.all(1, T.BORDER), border_radius=T.RADIUS)
self.idle = ft.Column([stage, C.feature_row(live_features())], spacing=T.S4, expand=True,
self.idle = ft.Column([stage], spacing=T.S4, expand=True,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
self.root = ft.Column([
app.top_bar(heading, sub),
@@ -233,6 +224,7 @@ class LiveView:
elsewhere = running and self.mode in ("browser", "mpv")
self.idle.visible = live is None or elsewhere
self.idle_start.visible = live is None
self.start_btn.visible = not running and not self.idle_start.visible # one Start on screen, not two
self.idle_open.visible = elsewhere
self.idle_title.value = (tr("Streaming to an mpv window") if elsewhere and self.mode == "mpv" else
tr("Streaming to your browser") if elsewhere else tr("Ready when you are"))
+4 -10
View File
@@ -289,14 +289,7 @@ class MonitorView:
tr("Connect to your Steam Frame to watch its games, load, temperatures and battery "
"live."),
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")),
features=[
(ft.Icons.SPEED_ROUNDED, tr("Frame rate"),
tr("The running game's frames per second, as it plays.")),
(ft.Icons.THERMOSTAT_ROUNDED, tr("Load and temperatures"),
tr("CPU, GPU, memory, temperatures, power and battery.")),
(ft.Icons.LIST_ALT_ROUNDED, tr("Processes"),
tr("What's running, and a way to end what's stuck.")),
])], expand=True)
preview=C.ghost_preview("monitor"))], expand=True)
if self.root is None:
self.root = ft.Column([
app.top_bar(heading, sub, [self.overhead, self.live, self.reconnect, self.interval, self.pause_btn]),
@@ -518,7 +511,7 @@ class MonitorView:
def _apply_game(self, s: dict) -> list:
games = s.get("games") or []
self.last_games = games
game = games[0] if games else None
game = M.main_game(s)
was = self.game_card.visible
self.game_card.visible = game is not None
self.no_game.visible = game is None and self.filter.data == "game"
@@ -534,7 +527,8 @@ class MonitorView:
# once per game (not per tick): its artwork, by asset URL
self.game_art.content = C.art_fill(meta["art"], radius=T.RADIUS_SM, width=T.px(96), height=T.px(96))
self.page_btn.visible = meta["in_library"]
kind = {"pcvr": tr("PC VR via Proton"), "linux": tr("Linux app")}.get(game.get("kind"), tr("Quest game"))
kind = {"pcvr": tr("PC VR via Proton"), "linux": tr("Linux app"),
"steam": tr("Steam game")}.get(game.get("kind"), tr("Quest game"))
self.game_title.value = game.get("title") or pkg
self.game_sub.value = " · ".join(x for x in (kind, tr("running for {t}").format(
t=M.fmt_duration(game.get("elapsed"))) if game.get("elapsed") is not None else "") if x)
+1 -8
View File
@@ -121,14 +121,7 @@ class ScreenshotsView:
C.empty_state(G.SHOT, tr("Every shot you take in the headset"),
tr("Connect to your Steam Frame to see its screenshots here."),
C.primary(tr("Connect"), ft.Icons.LINK_ROUNDED, lambda e: app.go("frame")),
features=[
(G.SHOT, tr("Sorted by day"),
tr("All of Steam's headset screenshots, newest first.")),
(ft.Icons.VIDEOGAME_ASSET_OUTLINED, tr("Matched to your games"),
tr("Each shot is labeled with the game you were playing.")),
(ft.Icons.DOWNLOAD_ROUNDED, tr("Save them to this PC"),
tr("Download one, a selection or all of them.")),
])], expand=True)
preview=C.ghost_preview("screenshots"))], expand=True)
if self.root is None:
# the selection bar below the grid (as in the Library): above it, it pushed the cards down mid-drag
self.root = ft.Column([app.top_bar(heading, sub), self.toolbar, self.menu, self.sel_bar, self.status],
+1 -1
View File
@@ -73,7 +73,7 @@ class WelcomeView:
if t_state == "error" else [])),
self.step(2, tr("Connect your Steam Frame"), f_text, f_state,
*([] if f_state == "done"
else [C.secondary(tr("Set up the Frame"), ft.Icons.ARROW_FORWARD_ROUNDED,
else [C.secondary(tr("Set up your Frame"), ft.Icons.ARROW_FORWARD_ROUNDED,
lambda e: app.go("frame"))])),
self.step(3, tr("Add your games"),
tr("A folder with Quest, Android or PC VR games."),
+39
View File
@@ -108,6 +108,9 @@ def frame_tree(root, home):
proc(root, 302, "lus4xvrplayerov", 301, cpu=50, fds={3: ("/dev/kgsl-3d0", 5000)},
cgroup="/user.slice/libpod-abc.scope/container", cmdline=["cn.vr4p.oculus4xvrplayerov"])
proc(root, 400, "bash", 1, cmdline=["bash", "-c", "sleep 9"])
# the agent's shortcut worker during an uninstall names the game's launch.sh: not the game
proc(root, 500, "python3", 1, cmdline=["python3", "{agent}", "_shortcuts_worker",
json.dumps({"remove": [{"exe": f'"{launcher}"'}]})])
return base
@@ -119,6 +122,9 @@ def fa(monkeypatch, tmp_path):
a = load_agent(monkeypatch, home)
root = tmp_path / "frame"
frame_tree(root, home)
agent_file = next(iter(a.AGENT_FILES))
cmd = root / "proc" / "500" / "cmdline"
cmd.write_text(cmd.read_text().replace("{agent}", agent_file))
monkeypatch.setattr(a, "PROC", str(root / "proc"))
monkeypatch.setattr(a, "SYS", str(root / "sys"))
monkeypatch.setattr(a, "POWER_SUPPLY", str(root / "power_supply"))
@@ -177,6 +183,7 @@ def test_processes_games_and_filters(fa):
assert 3 not in names and 2 not in names # kernel threads
assert {names[pid]["game"] for pid in (200, 300, 301, 302)} == {GAME}
assert names[100]["group"] == "steam" and names[101]["group"] == "steamvr" and names[400]["group"] == "other"
assert names[500]["game"] is None
assert names[302]["name"] == "cn.vr4p.oculus4xvrplayerov" # the full name, not the truncated comm
assert names[101]["critical"] and names[1]["locked"]
# default filter: the game's processes + the busiest others as context
@@ -356,3 +363,35 @@ def test_fast_ticks_scan_processes_every_2_s(fa):
seen.append("procs" in m.sample(wall=2000.0 + clock["t"] - 100.0))
assert seen == [False] * 7 + [True]
assert 0.1 in fa.MON_INTERVALS and fa.MON_DEFAULT_INTERVAL == 0.5
def test_steam_games_and_overlay_apps(fa):
"""A Steam store game (Job Simulator in Lepton, started by Steam's reaper) counts as a game with its Steam name;
an overlay app (Temporal Reality) is flagged and listed after it, and isn't the game in front."""
from frameport.frame.monitor import main_game
from frameport.ui.frame_card import now_playing
root, home = fa.ROOT, fa.ROOT.parent / "home"
w(home / ".local/share/Steam/steamapps/appmanifest_448280.acf",
'"AppState"\n{\n\t"appid"\t\t"448280"\n\t"name"\t\t"Job Simulator"\n\t"installdir"\t\t"Job Simulator"\n}\n')
ov = home / "Applications/quest-frame/linux.overlay"
w(ov / "deployment.json", json.dumps({"package": "linux.overlay", "appid": 1234, "title": "Temporal Reality",
"kind": "linux", "overlay": {"registered": True}}))
proc(root, 600, "bash", 1, start=50, cmdline=["bash", str(ov / "launch.sh")]) # started long before the game
proc(root, 601, "TemporalReality", 600, start=50, cmdline=[str(ov / "app/TemporalReality")])
proc(root, 700, "reaper", 100, start=150, cmdline=["reaper", "SteamLaunch", "AppId=448280", "--", "lepton"])
proc(root, 701, "lepton", 700, start=150, cmdline=["/bin/bash", "lepton", "waitforexitandrun"])
proc(root, 702, "reaper", 100, start=150, cmdline=["reaper", "SteamLaunch", f"AppId={fa.STEAMVR_APPID}"])
m, _ = monitor(fa)
s = m.sample(wall=2000.0)
games = {g["package"]: g for g in s["games"]}
steam = games["steam:448280"]
assert steam["title"] == "Job Simulator" and steam["kind"] == "steam" and not steam["overlay"]
assert games["linux.overlay"]["overlay"] and f"steam:{fa.STEAMVR_APPID}" not in games
assert s["games"][-1]["package"] == "linux.overlay" # overlay apps last
assert {p["pid"]: p["game"] for p in m.procs}[701] == "steam:448280"
# the newest game first (the fixture's game started at 100, Job Simulator at 150)
assert main_game(s)["package"] == "steam:448280" and now_playing(s)["title"] == "Job Simulator"
# an overlay alone is nobody's game
alone = {"games": [g for g in s["games"] if g["overlay"]]}
assert main_game(alone) is None and now_playing(alone) is None
+9
View File
@@ -168,3 +168,12 @@ def test_as_icon_turns_glyphs_into_tinted_images():
assert isinstance(img, ft.Image) and img.color == T.ACCENT and img.src.endswith("frame.svg")
assert isinstance(C.as_icon(ft.Icons.TUNE_ROUNDED, 20), ft.Icon)
assert C._btn_icon(ft.Icons.TUNE_ROUNDED, T.TEXT) == ft.Icons.TUNE_ROUNDED # buttons colour Material icons
def test_builtin_text_is_readable_on_every_card_surface():
# TEXT_3 carries the 11-12 px meta text (C.meta): it was #717583 (3.6:1 on SURFACE_2), now >= 4.5:1 (WCAG AA)
for name, theme in T.BUILTIN.items():
c = theme["colors"]
for token in ("TEXT", "TEXT_2", "TEXT_3"):
for surface in ("BG", "SIDEBAR", "SURFACE", "SURFACE_2"):
assert T.contrast(c[token], c[surface]) >= 4.5, (name, token, surface)