feat: show the handheld demo and steps per tab

make_demo.py records a second variant: the full-screen interface at the
640x480 screen of an R36S, on an ArkOS root made with bwrap, started as
the Tools menu launcher starts it (RETROBIOS_PAD=gptokeyb), driven by the
keys gptokeyb sends for the buttons. demo.json holds one entry per
variant with its alt text. No Windows variant: under pwsh on Linux the
installer's summary gives --dest /mnt/e/bios back.

The quick install box of Get your files gets an "On the device" tab with
the handheld recording, the terminal recording under the others but
Windows, and, under the text of each computer tab, a drawing of opening
the terminal and pasting (sitesteps.py, inline SVG in the theme's
colours, title and desc). The terminal steps name the physical action.
This commit is contained in:
Abdessamad Derraz committed 2026-11-09 07:35:03 +01:00
1 parent 16ec5fa2d2
commit 77fef7289f
19 files changed
+908 -172

No files matched your search

+21 -7
View File
@@ -1,9 +1,23 @@
{
"width": 800,
"height": 460,
"setup": "ArkOS",
"games": 6,
"systems": 6,
"files": 43,
"size": "15.1 MB"
"default": {
"width": 800,
"height": 460,
"setup": "ArkOS",
"games": 6,
"systems": 6,
"files": 43,
"size": "15.1 MB",
"alt": "Recording of the one-line command in a terminal. The installer finds an ArkOS SD card with 6 games, plans 43 BIOS files (15.1 MB) for them, installs them and shows the summary."
},
"handheld": {
"width": 640,
"height": 480,
"setup": "ArkOS",
"games": 6,
"systems": 6,
"files": 43,
"size": "15.1 MB",
"pad": "gptokeyb",
"alt": "Recording of the full-screen installer on the 640 by 480 screen of a handheld, started from the Tools menu of ArkOS. It finds the 6 games on the card, plans 43 BIOS files (15.1 MB) for them, installs them and shows the summary. The A button chooses, and the bottom line names the buttons."
}
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+43
View File
@@ -0,0 +1,43 @@
# The installer demo of the "On the device" tab: the full-screen interface
# on ArkOS, at the 640x480 screen of an R36S, as the Tools menu launcher
# starts it. Run scripts/make_demo.py --variant handheld, which builds the
# card and ArkOS's root, serves the repository on loopback, starts VHS on
# this file from the repository root and encodes what it writes. Enter is
# what gptokeyb sends for A (ports/shared/retrobios.gptk).
Output tmp/demo/recorded-handheld.mp4
Set Shell "bash"
Set FontFamily "DejaVu Sans Mono"
Set FontSize 16
Set LineHeight 1.15
Set Width 640
Set Height 480
Set Padding 8
Set Framerate 25
Set TypingSpeed 35ms
Set CursorBlink false
Set Theme { "name": "RetroBIOS", "background": "#12131c", "foreground": "#e6e6f0", "cursor": "#aebcff", "selection": "#3a3d52", "black": "#12131c", "red": "#ef9a9a", "green": "#a5d6a7", "yellow": "#ffcc80", "blue": "#90caf9", "magenta": "#aebcff", "cyan": "#aebcff", "white": "#cfd8dc", "brightBlack": "#7c82a3", "brightRed": "#ef9a9a", "brightGreen": "#a5d6a7", "brightYellow": "#ffcc80", "brightBlue": "#90caf9", "brightMagenta": "#c9d2ff", "brightCyan": "#c9d2ff", "brightWhite": "#e6e6f0" }
# ArkOS's root, the card at /roms, the interface started.
Hide
Type 'exec "$RETROBIOS_DEMO_SESSION"'
Enter
Wait+Screen@60s /A Continue/
Sleep 300ms
Show
# The first screen: the system found, its BIOS folder.
Sleep 300ms
Screenshot tmp/demo/poster-handheld.png
Sleep 2500ms
Enter
# A: the plan, only the files the games on the card need.
Wait+Screen@60s /A Install/
Sleep 4500ms
Enter
# A: install. The progress, then the summary.
Wait+Screen@60s /RetroBIOS: Done/
Sleep 4500ms
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.
Binary file not shown.
+8 -4
View File
@@ -97,21 +97,25 @@ terminals:
windows:
label: Windows
how: >-
Open the Start menu. Type **PowerShell** and open it. Paste the line. Press Enter.
Open Start. Type **PowerShell**, press Enter. Click Copy. Right-click to paste. Press
Enter.
shell: powershell
macos:
label: macOS
how: >-
Open **Terminal**, in Applications, then Utilities. Paste the line. Press Return.
Press Cmd+Space. Type **Terminal**, press Return. Click Copy. Press Cmd+V. Press
Return.
shell: sh
linux:
label: Linux
how: Open a terminal. Paste the line. Press Enter.
how: >-
Open a terminal, often with Ctrl+Alt+T. Click Copy. Press Ctrl+Shift+V. Press Enter.
shell: sh
steamdeck:
label: Steam Deck
how: >-
Switch to Desktop Mode. Open **Konsole**. Paste with Ctrl+Shift+V. Press Enter.
Hold power, pick **Switch to Desktop**. Open **Konsole**. Click Copy. Press
Ctrl+Shift+V. Press Enter.
shell: sh
android:
label: Android (Termux)
+8 -8
View File
@@ -476,17 +476,17 @@ def clone_line(db: dict) -> str:
)
def demo_facts() -> dict:
"""What the installer demo shows, as make_demo.py measured it."""
return json.loads((DEMO_DIR / "demo.json").read_text(encoding="utf-8"))
def demo_facts(variant: str = "default") -> dict:
"""What an installer demo shows, as make_demo.py measured it: one entry
per variant (default, handheld)."""
facts = json.loads((DEMO_DIR / "demo.json").read_text(encoding="utf-8"))
return facts[variant]
def demo_description(facts: dict | None = None) -> str:
"""The demo in words, for a reader who cannot see it."""
facts = facts or demo_facts()
return (f"Recording of the one-line command in a terminal. The installer finds an "
f"{facts['setup']} SD card with {facts['games']} games, plans {facts['files']} "
f"BIOS files ({facts['size']}) for them, installs them and shows the summary.")
"""The demo in words, for a reader who cannot see it: the alt text
make_demo.py wrote from what it measured."""
return str((facts or demo_facts())["alt"])
def _existing_contributor_block(readme_path: str = "README.md") -> list[str]:
+15 -8
View File
@@ -96,6 +96,7 @@ import siteinputs
import retired_files
import siteagents
import sitestarlight
import sitesteps
from siteexports import generate_exports_page, load_export_reports
from siteguides import (
ALSO_RUNS,
@@ -4180,16 +4181,19 @@ def first_screen_words() -> list[str]:
# The demo files the site serves (docs_assets/demo/, scripts/make_demo.py):
# the GIF is the README's, the tape and demo.json are inputs.
DEMO_ASSETS = ("install.webm", "install.mp4", "install.png")
DEMO_ASSETS = tuple(f"{stem}.{ext}" for stem in ("install", "install-handheld")
for ext in ("webm", "mp4", "png"))
def install_demo() -> list[str]:
"""The installer demo: a video that loads once it is on screen, its
description for screen readers."""
facts = demo_facts()
def install_demo(variant: str = "default", name: str = "") -> list[str]:
"""An installer demo (default, or handheld): a video that loads once it
is on screen, its description for screen readers. *name* tells two
demos of one page apart (the tab each sits in)."""
facts = demo_facts(variant)
text = html.escape(demo_description(facts), quote=True)
own = f' variant="{variant}" name="{name}"' if name else ""
return [f'<InstallDemo description="{text}" width="{facts["width"]}" '
f'height="{facts["height"]}" />', ""]
f'height="{facts["height"]}"{own} />', ""]
def check_pass_line() -> str:
@@ -11942,6 +11946,10 @@ def generate_which_pack() -> str:
display = {key: load_platform_config(key, str(siteinputs.current().platforms)).get("platform", key)
for key in registry}
all_tabs = ("windows", "macos", "linux", "steamdeck", "android")
# The quick install box also runs on a game system: its tab shows the
# handheld recording (sitesteps).
box_tabs = (*all_tabs, "device")
box_steps, box_demos = sitesteps.install_box(box_tabs)
lines = [
"---",
f"terms: {', '.join(IOS_TERMS)}",
@@ -11988,13 +11996,12 @@ def generate_which_pack() -> str:
"",
"A **terminal** is a window where you type commands.",
"",
*terminal_tabs(all_tabs, {}),
*terminal_tabs(box_tabs, {}, under_steps=box_steps, under_command=box_demos),
"On Batocera, Recalbox and other game systems, run it on the device. "
"Your setup's page shows how.",
"",
*first_screen_words(),
"",
*install_demo(),
*trust_box(""),
"**Check your folder later.** Add `--check`. It changes nothing.",
"",
+279 -95
View File
@@ -1,35 +1,53 @@
#!/usr/bin/env python3
"""Record the installer demo shown in the README and on the site, once.
"""Record the installer demos shown in the README and on the site, once.
The recording is the full-screen installer run from the one-line command on
an ArkOS SD card that holds six games: it finds the card, plans only the BIOS
files those games need, installs them and shows the summary. Everything it
reads is local and the same on every machine:
Each recording is the installer on an ArkOS SD card that holds six games: it
finds the card, plans only the BIOS files those games need, installs them
and shows the summary. Two variants, by where the install box says the
command runs:
- default: the one-line command in a terminal on a Linux computer, the card
mounted at /media/EASYROMS. The README and every tab but one show it.
- handheld: the full-screen interface on ArkOS itself, at the 640x480
screen of an R36S, as the Tools menu launcher starts it (ports/shared/
tui.txt: install.py --tui, RETROBIOS_PAD=gptokeyb, RETROBIOS_IDLE_EXIT).
The keys typed are the ones gptokeyb sends for the buttons
(ports/shared/retrobios.gptk: A is Enter), and the bottom line names the
buttons. The "On the device" tab shows it.
No Windows variant: pwsh on Linux runs install.ps1's whole flow, but the
installer reads drive E: there only through its WSL layout, and its summary
gives the command back with --dest /mnt/e/bios, a path no Windows user sees.
Everything a recording reads is local and the same on every machine:
- The card is built under tmp/demo/ (bios/, the folders ArkOS writes on its
games partition, one game per console) and mounted at /media/EASYROMS in a
mount namespace of this user (unshare), on a tmpfs of a card's size. /media
and /mnt are emptied there, so no drive of this machine is shown.
games partition, one game per console) on a tmpfs of a card's size, in a
mount namespace of this user (unshare, or bwrap for the handheld, whose
root holds ArkOS's /home/ark, /opt/system and /roms). /media and /mnt are
emptied, so no drive of this machine is shown.
- The repository is served on 127.0.0.1 and named with RETROBIOS_BASE_URL:
the file lists, install/games/ and the BIOS files are this checkout's.
- The one-line command runs this checkout's install.sh and install.py. The
shell of the recording answers `curl -fsSL <site>/i` with a line that
starts the local install.sh, which skips the download of the installer and
its SHA-256 check and runs the rest of the bootstrap as the site's copy does.
- The one-line command runs this checkout's install.sh: the shell answers
`curl -fsSL <site>/i` with a line that starts the local install.sh, which
skips the download of the installer and its SHA-256 check and runs the
rest of the bootstrap as the site's copy does.
- HOME is an empty folder and the prompt is "$ ".
docs_assets/demo/install.tape drives VHS (github.com/charmbracelet/vhs, with
ttyd, ffmpeg and a Chromium on PATH). This script encodes what it records:
docs_assets/demo/<stem>.tape drives VHS (github.com/charmbracelet/vhs, with
ttyd, ffmpeg and a Chromium on PATH, and bwrap for the handheld). This
script encodes what it records:
docs_assets/demo/install.gif the README
docs_assets/demo/install.webm the site, with install.mp4 as fallback
docs_assets/demo/install.png the first frame shown, the video's poster
docs_assets/demo/demo.json what the recording shows, for the alt text
docs_assets/demo/install.gif the README (the default variant only)
docs_assets/demo/<stem>.webm the site, with <stem>.mp4 as fallback
docs_assets/demo/<stem>.png the first frame shown, the video's poster
docs_assets/demo/demo.json per variant, what it shows and its alt text
The site build copies the video files and never runs this script. Run it
again when a screen of the installer or the file list of its games changes:
python scripts/make_demo.py
python scripts/make_demo.py # both variants
python scripts/make_demo.py --variant handheld # one, the others kept
"""
from __future__ import annotations
@@ -48,20 +66,72 @@ import sys
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "docs_assets" / "demo"
TAPE = OUT / "install.tape"
WORK = ROOT / "tmp" / "demo"
# What the tape writes, read back here (paths relative to ROOT, the tape's
# working directory).
RECORDED = WORK / "recorded.mp4"
POSTER_SHOT = WORK / "poster.png"
MOUNT = "/media/EASYROMS"
FACTS = OUT / "demo.json"
CARD_SIZE = "29g"
SETUP = "ArkOS"
@dataclass(frozen=True)
class Variant:
"""One recording. *place* is "computer" (the card in a reader, the
one-line command typed) or "device" (the interface on the card's own
system). *os_type* is RETROBIOS_OS for the run."""
name: str
stem: str
mount: str
os_type: str
place: str
gif: bool = False
@property
def tape(self) -> Path:
return OUT / f"{self.stem}.tape"
@property
def suffix(self) -> str:
return "" if self.name == "default" else f"-{self.name}"
@property
def recorded(self) -> Path:
"""What the tape writes, read back here (relative to ROOT, the
tape's working directory)."""
return WORK / f"recorded{self.suffix}.mp4"
@property
def poster_shot(self) -> Path:
return WORK / f"poster{self.suffix}.png"
@property
def outputs(self) -> tuple[str, ...]:
names = (f"{self.stem}.webm", f"{self.stem}.mp4", f"{self.stem}.png")
return ((f"{self.stem}.gif",) if self.gif else ()) + names
VARIANTS: dict[str, Variant] = {
"default": Variant("default", "install", "/media/EASYROMS", "linux", "computer",
gif=True),
"handheld": Variant("handheld", "install-handheld", "/roms", "linux", "device"),
}
# The default variant's paths, as the tests and the README name them.
TAPE = VARIANTS["default"].tape
RECORDED = VARIANTS["default"].recorded
POSTER_SHOT = VARIANTS["default"].poster_shot
MOUNT = VARIANTS["default"].mount
# Where ArkOS keeps what the handheld recording needs: the Tools menu folder
# the launcher sits in (ports/tools/RetroBIOS.sh, RB_DIR), the user, and
# EASYROMS at /roms (install.py _OS_RULES, ArkOS).
ARK_HOME = "/home/ark"
ARK_TOOLS = "/opt/system/Tools"
ARK_INSTALLER = f"{ARK_TOOLS}/retrobios/install.py"
# The card: the folders ArkOS's default tree writes on EASYROMS (install.py
# _GAMES_PART_RULES reads launchimages/), and one game per console folder,
# named as the folder's extensions in install/games/setups/arkos.json allow.
@@ -79,13 +149,18 @@ GAME_BYTES = 256 * 1024
# cores read the region from (install/games/systems/sony-playstation.json).
PSX_LICENCE = b" Licensed by Sony Computer Entertainment Amer ica "
# Size targets of the outputs, in bytes: the README and two site pages.
LIMITS = {"install.gif": 1_500_000, "install.webm": 1_000_000,
"install.mp4": 1_000_000, "install.png": 150_000}
# Size targets of the outputs, in bytes, by extension: the README and the
# site's pages.
LIMITS = {".gif": 1_500_000, ".webm": 1_000_000, ".mp4": 1_000_000, ".png": 150_000}
GIF_FPS = 10
VIDEO_FPS = 25
def limits(variant: Variant) -> dict[str, int]:
"""Each output of a variant and its size target."""
return {name: LIMITS[Path(name).suffix] for name in variant.outputs}
def game_bytes(name: str) -> bytes:
"""Bytes of a game no catalog names, the same on every run."""
seed = hashlib.sha256(name.encode("utf-8")).digest()
@@ -133,10 +208,9 @@ def served_repository() -> Iterator[str]:
server.server_close()
def write_session(work: Path, names: list[str]) -> Path:
"""The program the recording's shell becomes: the card mounted in a
namespace of its own, then a clean interactive bash, or the command
given as arguments, with only the variables names lists."""
def _write_rc(work: Path) -> Path:
"""The startup file of the shell the recording types into: the prompt,
no history, and the site's address answered with the local bootstrap."""
rc = work / "bashrc"
rc.write_text(
"PS1='$ '\n"
@@ -151,67 +225,144 @@ def write_session(work: Path, names: list[str]) -> Path:
"clear\n",
encoding="utf-8",
)
session = work / "session.sh"
session.write_text(
"#!/bin/sh\n"
"# Written by scripts/make_demo.py.\n"
"set -eu\n"
return rc
def _computer_session(variant: Variant, work: Path, names: list[str]) -> str:
rc = _write_rc(work)
interactive = f'bash --noprofile --rcfile "{rc}" -i'
mount = variant.mount
return (
"inner='set -eu\n"
"mount -t tmpfs -o mode=0755 demo /media\n"
"mount -t tmpfs -o mode=0755 demo /mnt\n"
f"mkdir {MOUNT}\n"
f"mount -t tmpfs -o size={CARD_SIZE},mode=0755 EASYROMS {MOUNT}\n"
f'cp -a "$RETROBIOS_DEMO_CARD/." {MOUNT}/\n'
f"mkdir {mount}\n"
f"mount -t tmpfs -o size={CARD_SIZE},mode=0755 EASYROMS {mount}\n"
f'cp -a "$RETROBIOS_DEMO_CARD/." {mount}/\n'
'cd "$HOME"\n'
'exec unshare --user --map-user="$RETROBIOS_DEMO_UID" '
'--map-group="$RETROBIOS_DEMO_GID" env -i '
+ " ".join(f'{name}="${name}"' for name in names) + ' "$@"\'\n'
'if [ "$#" -eq 0 ]; then\n'
f' set -- bash --noprofile --rcfile "{rc}" -i\n'
f" set -- {interactive}\n"
"fi\n"
'exec unshare --user --mount --map-root-user /bin/sh -c "$inner" sh "$@"\n',
encoding="utf-8",
'exec unshare --user --mount --map-root-user /bin/sh -c "$inner" sh "$@"\n'
)
def _device_session(names: list[str]) -> str:
"""ArkOS's own root, made of this machine's /usr and /etc: the user and
the Tools menu folder ArkOS is recognised by, EASYROMS at /roms, and
the installer where the Tools menu launcher keeps it."""
binds = " ".join(
f"--symlink usr/{name} /{name}" for name in ("bin", "lib", "lib64", "sbin"))
return (
'if [ "$#" -eq 0 ]; then\n'
f' set -- python3 {ARK_INSTALLER} --tui\n'
"fi\n"
"exec bwrap --unshare-user --unshare-pid --unshare-ipc --unshare-uts --hostname arkos "
'--uid "$RETROBIOS_DEMO_UID" --gid "$RETROBIOS_DEMO_GID" '
f"--ro-bind /usr /usr {binds} --ro-bind /etc /etc --proc /proc --dev /dev "
f"--tmpfs /tmp --dir {ARK_HOME} --dir /opt/inttools --dir {ARK_TOOLS} "
f'--ro-bind "$RETROBIOS_DEMO_INSTALLER" {ARK_INSTALLER} '
f'--ro-bind "$RETROBIOS_DEMO_CARD" /card --size 31138512896 --tmpfs /roms '
f"--chdir {ARK_HOME} "
"/bin/sh -c 'cp -a /card/. /roms/ && exec env -i \"$@\"' sh "
+ " ".join(f'{name}="${name}"' for name in names) + ' "$@"\n'
)
def write_session(work: Path, names: list[str], variant: Variant = VARIANTS["default"]) -> Path:
"""The program the recording's shell becomes: the card mounted in a
namespace of its own, then a clean interactive shell (or, on the
device, the interface), or the command given as arguments, with only
the variables names lists."""
body = (_device_session(names) if variant.place == "device"
else _computer_session(variant, work, names))
session = work / f"session{variant.suffix}.sh"
session.write_text("#!/bin/sh\n# Written by scripts/make_demo.py.\nset -eu\n" + body,
encoding="utf-8")
session.chmod(0o755)
return session
def session_env(base_url: str, work: Path, card: Path) -> dict[str, str]:
def session_env(base_url: str, work: Path, card: Path,
variant: Variant = VARIANTS["default"]) -> dict[str, str]:
"""The variables of the run, and of nothing else: the namespace starts
from an empty environment."""
own = {
"RETROBIOS_DEMO_CARD": str(card),
"RETROBIOS_DEMO_UID": str(os.getuid()),
"RETROBIOS_DEMO_GID": str(os.getgid()),
}
if variant.place == "device":
return {
**own,
"HOME": ARK_HOME,
"PATH": "/usr/local/bin:/usr/bin:/bin",
"LANG": "C.UTF-8",
# ArkOS's console (ports/tools/RetroBIOS.sh: console 1).
"TERM": "linux",
"RETROBIOS_OS": variant.os_type,
"RETROBIOS_BASE_URL": base_url,
# What ports/shared/tui.txt exports before the interface starts.
"RETROBIOS_PAD": "gptokeyb",
"RETROBIOS_IDLE_EXIT": "300",
"RETROBIOS_DEMO_INSTALLER": str(ROOT / "install.py"),
}
home = work / "home"
home.mkdir(parents=True, exist_ok=True)
return {
**own,
"HOME": str(home),
"PATH": "/usr/local/bin:/usr/bin:/bin",
"LANG": "C.UTF-8",
"TERM": "xterm-256color",
"TMPDIR": str(work / "scratch"),
"RETROBIOS_OS": "linux",
"RETROBIOS_OS": variant.os_type,
"RETROBIOS_BASE_URL": base_url,
# What the downloaded bootstrap sets: commands given back are the
# one-line command, not a path to a temporary copy.
# What the downloaded bootstrap sets: commands given back are
# the one-line command, not a path to a temporary copy.
"RETROBIOS_BOOTSTRAP": "sh",
"RETROBIOS_DEMO_BOOTSTRAP": str(ROOT / "install.sh"),
"RETROBIOS_DEMO_CARD": str(card),
"RETROBIOS_DEMO_UID": str(os.getuid()),
"RETROBIOS_DEMO_GID": str(os.getgid()),
}
def format_size(size: int) -> str:
"""The size as the installer writes it."""
def _installer() -> object:
spec = importlib.util.spec_from_file_location("retrobios_install", ROOT / "install.py")
if spec is None or spec.loader is None:
raise RuntimeError("install.py cannot be loaded")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return str(module.format_size(size))
return module
def measure(session: Path, env: dict[str, str]) -> dict:
def format_size(size: int) -> str:
"""The size as the installer writes it."""
return str(_installer().format_size(size)) # type: ignore[attr-defined]
def describe(variant: str, facts: dict) -> str:
"""A recording in words, for a reader who cannot see it: its alt text."""
plan = (f"plans {facts['files']} BIOS files ({facts['size']}) for them, installs them "
"and shows the summary.")
if variant == "handheld":
return (f"Recording of the full-screen installer on the {facts['width']} by "
f"{facts['height']} screen of a handheld, started from the Tools menu of "
f"{facts['setup']}. It finds the {facts['games']} games on the card, {plan} The "
"A button chooses, and the bottom line names the buttons.")
return (f"Recording of the one-line command in a terminal. The installer finds an "
f"{facts['setup']} SD card with {facts['games']} games, {plan}")
def measure(session: Path, env: dict[str, str], variant: Variant = VARIANTS["default"]) -> dict:
"""What the recording shows, read from the same run without the screen:
the games read on the card and the files planned for them."""
command = [str(session), sys.executable, str(ROOT / "install.py"), "--platform", "retroarch",
"--dest", f"{MOUNT}/bios", "--for-games", "--dry-run", "--events"]
installer = ARK_INSTALLER if variant.place == "device" else str(ROOT / "install.py")
options = ([] if variant.place == "device" else
["--platform", "retroarch", "--dest", f"{variant.mount}/bios", "--for-games"])
command = [str(session), "python3" if variant.place == "device" else sys.executable,
installer, *options, "--dry-run", "--events", "--no-input"]
result = subprocess.run(command, env=env, capture_output=True, text=True, check=False)
if result.returncode != 0:
raise RuntimeError(f"the dry run failed ({result.returncode}):\n{result.stderr}")
@@ -219,8 +370,9 @@ def measure(session: Path, env: dict[str, str]) -> dict:
games = next(e for e in events if e["event"] == "games")
check = next(e for e in events if e["event"] == "check")
size = {key.lower(): int(value) for key, value in
re.findall(r"^Set (Width|Height) (\d+)$", TAPE.read_text(encoding="utf-8"), re.M)}
return {
re.findall(r"^Set (Width|Height) (\d+)$", variant.tape.read_text(encoding="utf-8"),
re.M)}
facts = {
"width": size["width"],
"height": size["height"],
"setup": SETUP,
@@ -229,61 +381,93 @@ def measure(session: Path, env: dict[str, str]) -> dict:
"files": check["missing"],
"size": format_size(check["bytes_needed"]),
}
if variant.name == "handheld":
facts["pad"] = env["RETROBIOS_PAD"]
facts["alt"] = describe(variant.name, facts)
return facts
def ffmpeg(*args: str) -> None:
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", *args], check=True)
def encode(recorded: Path, poster: Path, out: Path, scratch: Path) -> None:
palette = scratch / "palette.png"
ffmpeg("-i", str(recorded), "-vf", f"fps={GIF_FPS},palettegen=max_colors=48:stats_mode=full",
str(palette))
ffmpeg("-i", str(recorded), "-i", str(palette), "-lavfi",
f"fps={GIF_FPS}[v];[v][1:v]paletteuse=dither=none:diff_mode=rectangle",
"-loop", "0", str(out / "install.gif"))
ffmpeg("-i", str(recorded), "-an", "-r", str(VIDEO_FPS), "-c:v", "libvpx-vp9",
def encode(variant: Variant, out: Path, scratch: Path) -> None:
recorded, stem = str(variant.recorded), variant.stem
if variant.gif:
palette = scratch / "palette.png"
ffmpeg("-i", recorded, "-vf", f"fps={GIF_FPS},palettegen=max_colors=48:stats_mode=full",
str(palette))
ffmpeg("-i", recorded, "-i", str(palette), "-lavfi",
f"fps={GIF_FPS}[v];[v][1:v]paletteuse=dither=none:diff_mode=rectangle",
"-loop", "0", str(out / f"{stem}.gif"))
ffmpeg("-i", recorded, "-an", "-r", str(VIDEO_FPS), "-c:v", "libvpx-vp9",
"-b:v", "0", "-crf", "42", "-row-mt", "1", "-deadline", "good",
"-pix_fmt", "yuv420p", str(out / "install.webm"))
ffmpeg("-i", str(recorded), "-an", "-r", str(VIDEO_FPS), "-c:v", "libx264",
"-pix_fmt", "yuv420p", str(out / f"{stem}.webm"))
ffmpeg("-i", recorded, "-an", "-r", str(VIDEO_FPS), "-c:v", "libx264",
"-preset", "veryslow", "-crf", "30", "-tune", "animation", "-pix_fmt", "yuv420p",
"-movflags", "+faststart", str(out / "install.mp4"))
ffmpeg("-i", str(poster), "-pred", "mixed", "-compression_level", "100",
str(out / "install.png"))
"-movflags", "+faststart", str(out / f"{stem}.mp4"))
ffmpeg("-i", str(variant.poster_shot), "-pred", "mixed", "-compression_level", "100",
str(out / f"{stem}.png"))
def check_sizes(out: Path) -> list[str]:
def check_sizes(out: Path, variant: Variant = VARIANTS["default"]) -> list[str]:
return [f"{name}: {(out / name).stat().st_size:,} bytes, over {limit:,}"
for name, limit in LIMITS.items() if (out / name).stat().st_size > limit]
for name, limit in limits(variant).items() if (out / name).stat().st_size > limit]
def load_facts() -> dict:
"""demo.json, an empty mapping before the first recording."""
if not FACTS.is_file():
return {}
return json.loads(FACTS.read_text(encoding="utf-8"))
def record(variant: Variant) -> dict:
"""Record one variant into OUT and return what it shows."""
card = WORK / "card"
build_card(card)
with served_repository() as base_url:
env = session_env(base_url, WORK, card, variant)
session = write_session(WORK, sorted(env), variant)
facts = measure(session, env, variant)
vhs_env = {**os.environ, **env, "PATH": os.environ.get("PATH", ""),
"HOME": os.environ.get("HOME", ""),
"RETROBIOS_DEMO_SESSION": str(session)}
subprocess.run(["vhs", str(variant.tape.relative_to(ROOT))], cwd=ROOT, env=vhs_env,
check=True)
encode(variant, OUT, WORK / "scratch")
return facts
def needed_tools(variant: Variant) -> list[str]:
tools = ["vhs", "ttyd", "ffmpeg"]
return [*tools, "bwrap" if variant.place == "device" else "unshare"]
def main() -> None:
parser = argparse.ArgumentParser(allow_abbrev=False,
description="Record the installer demo (VHS).")
parser.parse_args()
missing = [tool for tool in ("vhs", "ttyd", "ffmpeg", "unshare") if shutil.which(tool) is None]
description="Record the installer demos (VHS).")
parser.add_argument("--variant", action="append", choices=sorted(VARIANTS),
help="record this variant only (repeatable); every variant by default")
args = parser.parse_args()
chosen = [VARIANTS[name] for name in (args.variant or VARIANTS)]
missing = sorted({tool for variant in chosen for tool in needed_tools(variant)
if shutil.which(tool) is None})
if missing:
sys.exit(f"Missing on PATH: {', '.join(missing)} (VHS: github.com/charmbracelet/vhs)")
if WORK.exists():
shutil.rmtree(WORK)
(WORK / "scratch").mkdir(parents=True)
facts = load_facts()
over: list[str] = []
try:
card = WORK / "card"
build_card(card)
with served_repository() as base_url:
env = session_env(base_url, WORK, card)
session = write_session(WORK, sorted(env))
facts = measure(session, env)
vhs_env = {**os.environ, **env, "PATH": os.environ.get("PATH", ""),
"HOME": os.environ.get("HOME", ""),
"RETROBIOS_DEMO_SESSION": str(session)}
subprocess.run(["vhs", str(TAPE.relative_to(ROOT))], cwd=ROOT, env=vhs_env,
check=True)
encode(RECORDED, POSTER_SHOT, OUT, WORK / "scratch")
(OUT / "demo.json").write_text(json.dumps(facts, indent=2) + "\n", encoding="utf-8")
for name in (*LIMITS, "demo.json"):
print(f"{(OUT / name).relative_to(ROOT)} {(OUT / name).stat().st_size:,} bytes")
over = check_sizes(OUT)
for variant in chosen:
if WORK.exists():
shutil.rmtree(WORK)
(WORK / "scratch").mkdir(parents=True)
facts[variant.name] = record(variant)
for name in variant.outputs:
print(f"{(OUT / name).relative_to(ROOT)} {(OUT / name).stat().st_size:,} bytes")
over += check_sizes(OUT, variant)
ordered = {name: facts[name] for name in VARIANTS if name in facts}
FACTS.write_text(json.dumps(ordered, indent=2) + "\n", encoding="utf-8")
if over:
sys.exit("Over the size targets:\n " + "\n ".join(over))
finally:
+7 -2
View File
@@ -460,11 +460,14 @@ PLACES = ("device", "server")
def terminal_tabs(
tabs: tuple[str, ...], args: dict[str, str], indent: str = "", steps: bool = True,
how: dict[str, str] | None = None,
under_steps: dict[str, list[str]] | None = None,
under_command: dict[str, list[str]] | None = None,
) -> list[str]:
"""Content tabs, one per place the command runs, each with how to open
the terminal there. Tab labels are shared across the site, so the reader's
choice carries from page to page. *how* replaces the shared text of a tab
for one setup."""
for one setup. *under_steps* and *under_command* add lines to a tab
after its text and after its command (sitesteps.install_box)."""
items = []
for key in tabs:
label, shared, shell = TERMINALS[key]
@@ -472,7 +475,9 @@ def terminal_tabs(
label = "" # one place, which the text around already names
text = (how or {}).get(key) or shared
body = [text, ""] if steps else []
items.append((label, body + fence(shell, install_command(shell, args.get(key, "")))))
body += (under_steps or {}).get(key, [])
items.append((label, body + fence(shell, install_command(shell, args.get(key, "")))
+ [""] + (under_command or {}).get(key, [])))
return markup.tabs(items, indent)
+269
View File
@@ -0,0 +1,269 @@
"""What the install box of Get your files shows under each tab.
Two things per tab, both drawn from data:
- The steps that differ by system, opening a terminal and pasting the line,
as an inline SVG: one card per step, a pictogram and a short caption,
drawn with sitediagrams' primitives so it takes the theme's colours in
light and dark. The text steps above it (platforms/_guides.yml,
terminals) stay the accessible source. The SVG says the same in its title
and desc. The pictograms are generic shapes (a menu, a search field, a
window, keys, a mouse, the power symbol), no vendor's interface or logo.
- The installer demo recorded for that place (scripts/make_demo.py): the
full-screen interface on a handheld under "On the device", the terminal
recording under the others but Windows. No Windows recording is
published (make_demo.py says why), and a curl line recorded in a Linux
terminal under the PowerShell command would contradict it.
"""
from __future__ import annotations
from dataclasses import dataclass
from xml.sax.saxutils import escape
import sitemarkup as markup
from sitediagrams import (
FONT,
MONO_STYLE,
NARROW,
STROKE,
TEXT_SIZE,
WIDE,
_badge,
_badge_side,
_line,
_n,
_rect,
_text,
wrap,
)
KEY_SIZE = 11.5
ICON_H = 44.0
@dataclass(frozen=True)
class Step:
"""One card: a pictogram (*icon* and its *word*, the text it shows:
keys joined by "+", the word typed in a search field) and a caption."""
icon: str
caption: str
word: str = ""
STEPS: dict[str, tuple[str, tuple[Step, ...]]] = {
"windows": ("Windows: open PowerShell and paste the line", (
Step("menu", "Open the Start menu"),
Step("search", "Type PowerShell and press Enter", "PowerShell"),
Step("copy", "Click Copy, the button by the line"),
Step("mouse", "Right-click in the window to paste"),
Step("keys", "Press Enter", "Enter"),
)),
"macos": ("macOS: open Terminal and paste the line", (
Step("keys", "Press Cmd+Space", "Cmd+Space"),
Step("search", "Type Terminal and press Return", "Terminal"),
Step("copy", "Click Copy, the button by the line"),
Step("keys", "Press Cmd+V to paste", "Cmd+V"),
Step("keys", "Press Return", "Return"),
)),
"linux": ("Linux: open a terminal and paste the line", (
Step("keys", "Open a terminal, often Ctrl+Alt+T", "Ctrl+Alt+T"),
Step("copy", "Click Copy, the button by the line"),
Step("keys", "Press Ctrl+Shift+V to paste", "Ctrl+Shift+V"),
Step("keys", "Press Enter", "Enter"),
)),
"steamdeck": ("Steam Deck: open Konsole and paste the line", (
Step("power", "Hold power, pick Switch to Desktop"),
Step("window", "Open Konsole"),
Step("copy", "Click Copy, the button by the line"),
Step("keys", "Press Ctrl+Shift+V to paste", "Ctrl+Shift+V"),
Step("keys", "Press Enter", "Enter"),
)),
}
# The recording each tab shows (docs_assets/demo/demo.json keys), "" for none.
TAB_DEMOS = {"device": "handheld", "windows": ""}
DEFAULT_DEMO = "default"
def demo_variant(tab: str) -> str:
return TAB_DEMOS.get(tab, DEFAULT_DEMO)
def description(tab: str) -> str:
"""The steps in words: the SVG's desc."""
return " ".join(f"{index}. {step.caption}." for index, step in
enumerate(STEPS[tab][1], start=1))
# Pictograms, drawn in a box *w* wide and ICON_H high from (x, y).
def _stroke(d: str, cls: str = "rb-d-line", width: float = 1.5) -> str:
return f'<path d="{d}" fill="none" stroke="{STROKE}" stroke-width="{_n(width)}" class="{cls}"/>'
def _key_width(label: str) -> float:
return max(20.0, len(label) * KEY_SIZE * 0.62 + 10)
def _keys(x: float, y: float, w: float, word: str) -> list[str]:
labels = word.split("+")
total = sum(_key_width(label) for label in labels) + 4 * (len(labels) - 1)
left = x + (w - total) / 2
out = []
top = y + ICON_H / 2 - 12
for label in labels:
width = _key_width(label)
out.append(_rect(left, top, width, 24, accent=True))
out.append(
f'<text x="{_n(left + width / 2)}" y="{_n(top + 16.5)}" font-size="{_n(KEY_SIZE)}" '
f'text-anchor="middle" fill="currentColor" style="{MONO_STYLE}">{escape(label)}</text>')
left += width + 4
return out
def _menu(x: float, y: float, w: float) -> list[str]:
cx, top = x + w / 2, y + 4
out = [_rect(cx - 22, top, 44, 36)]
out += [_stroke(f"M{_n(cx - 14)} {_n(top + 10 + 8 * i)} H{_n(cx + 14)}") for i in range(3)]
return out
def _search(x: float, y: float, w: float, word: str) -> list[str]:
width = min(w - 4, len(word) * KEY_SIZE * 0.62 + 34)
left, top = x + (w - width) / 2, y + ICON_H / 2 - 12
return [
_rect(left, top, width, 24, accent=True),
f'<circle cx="{_n(left + 11)}" cy="{_n(top + 11)}" r="5" fill="none" stroke="{STROKE}" '
'stroke-width="1.5" class="rb-d-line"/>',
_stroke(f"M{_n(left + 15)} {_n(top + 15)} L{_n(left + 19)} {_n(top + 19)}"),
f'<text x="{_n(left + 25)}" y="{_n(top + 16.5)}" font-size="{_n(KEY_SIZE)}" '
f'fill="currentColor" style="{MONO_STYLE}">{escape(word)}</text>',
]
def _window(x: float, y: float, w: float) -> list[str]:
cx, top = x + w / 2, y + 2
return [
_rect(cx - 32, top, 64, 40),
_stroke(f"M{_n(cx - 32)} {_n(top + 9)} H{_n(cx + 32)}"),
f'<text x="{_n(cx - 24)}" y="{_n(top + 28)}" font-size="{_n(KEY_SIZE)}" '
f'fill="currentColor" style="{MONO_STYLE}">&gt;_</text>',
]
def _copy(x: float, y: float, w: float) -> list[str]:
cx, top = x + w / 2, y + 6
return [_rect(cx - 12, top, 18, 24), _rect(cx - 6, top + 7, 18, 24, accent=True)]
def _mouse(x: float, y: float, w: float) -> list[str]:
cx, top = x + w / 2, y + 2
return [
f'<rect x="{_n(cx - 13)}" y="{_n(top)}" width="26" height="40" rx="12" fill="none" '
f'stroke="{STROKE}" stroke-width="1.5" class="rb-d-line"/>',
# The right button, the one to press.
f'<path d="M{_n(cx + 1)} {_n(top + 1)} A12 12 0 0 1 {_n(cx + 12)} {_n(top + 13)} '
f'L{_n(cx + 1)} {_n(top + 13)} Z" fill="currentColor" class="rb-d-num"/>',
_stroke(f"M{_n(cx)} {_n(top)} V{_n(top + 14)} M{_n(cx - 13)} {_n(top + 14)} "
f"H{_n(cx + 13)}"),
]
def _power(x: float, y: float, w: float) -> list[str]:
cx, cy = x + w / 2, y + ICON_H / 2 + 2
return [
_stroke(f"M{_n(cx - 9)} {_n(cy - 11)} A14 14 0 1 0 {_n(cx + 9)} {_n(cy - 11)}", width=2.5),
_stroke(f"M{_n(cx)} {_n(cy - 18)} V{_n(cy - 2)}", width=2.5),
]
def _icon(step: Step, x: float, y: float, w: float) -> list[str]:
if step.icon == "keys":
return _keys(x, y, w, step.word)
if step.icon == "search":
return _search(x, y, w, step.word)
return {"menu": _menu, "window": _window, "copy": _copy, "mouse": _mouse,
"power": _power}[step.icon](x, y, w)
def _wide(steps: tuple[Step, ...]) -> tuple[float, float, list[str]]:
"""The cards in a row, joined by arrows."""
gap = 14.0
width = (WIDE - gap * (len(steps) - 1) - 2) / len(steps)
captions = [wrap(step.caption, width - 12, TEXT_SIZE) for step in steps]
height = 34 + ICON_H + 10 + max(len(lines) for lines in captions) * 18 + 8
out: list[str] = []
for index, (step, lines) in enumerate(zip(steps, captions)):
x = 1 + index * (width + gap)
out.append(_rect(x, 1, width, height))
out += _badge(x + 8, 8, str(index + 1))
out += _icon(step, x + 4, 30, width - 8)
y = 30 + ICON_H + 24
for line in lines:
out.append(_text(x + width / 2, y, line, anchor="middle"))
y += 18
if index < len(steps) - 1:
mid = 1 + height / 2
out += _line([(x + width + 1, mid), (x + width + gap - 1, mid)])
return WIDE, height + 2, out
def _narrow(steps: tuple[Step, ...]) -> tuple[float, float, list[str]]:
"""One card a row: number, pictogram, caption."""
icon_w, row_gap = 112.0, 8.0
text_x = 8 + _badge_side("9") + 6 + icon_w + 8
out: list[str] = []
y = 1.0
for index, step in enumerate(steps):
lines = wrap(step.caption, NARROW - text_x - 8, TEXT_SIZE)
height = max(ICON_H + 12, len(lines) * 18 + 16)
out.append(_rect(1, y, NARROW - 2, height))
out += _badge(8, y + height / 2 - 10, str(index + 1))
out += _icon(step, 8 + _badge_side("9") + 6, y + (height - ICON_H) / 2, icon_w)
ty = y + height / 2 - (len(lines) - 1) * 9 + 5
for line in lines:
out.append(_text(text_x, ty, line))
ty += 18
y += height + row_gap
return NARROW, y - row_gap + 1, out
def _svg(tab: str, layout: str) -> str:
title, steps = STEPS[tab]
width, height, body = (_wide if layout == "wide" else _narrow)(steps)
base = f"rb-steps-{tab}-{layout}"
return (
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {_n(width)} {_n(height)}" '
f'width="{_n(width)}" height="{_n(height)}" role="img" '
f'aria-labelledby="{base}-t {base}-d" font-family="{FONT}" '
f'class="rb-diagram rb-diagram-{layout}">'
f'<title id="{base}-t">{escape(title)}</title>'
f'<desc id="{base}-d">{escape(description(tab))}</desc>'
+ "".join(body) + "</svg>"
)
def steps_figure(tab: str) -> list[str]:
"""The figure of a tab's steps, both layouts, or nothing for a tab with
no drawing."""
if tab not in STEPS:
return []
return [f'<figure class="rb-figure rb-steps" {markup.search_exclude()}>',
_svg(tab, "wide") + _svg(tab, "narrow"), "</figure>", ""]
def tab_demo(tab: str) -> list[str]:
"""The recording a tab shows, as generate_site writes the component."""
from generate_site import install_demo # noqa: PLC0415
variant = demo_variant(tab)
return install_demo(variant, tab) if variant else []
def install_box(tabs: tuple[str, ...]) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
"""What terminal_tabs puts under each tab's steps and under its command."""
return ({tab: steps_figure(tab) for tab in tabs},
{tab: tab_demo(tab) for tab in tabs})
+143 -38
View File
@@ -1,9 +1,12 @@
"""The installer demo of the README and the site (scripts/make_demo.py).
"""The installer demos of the README and the site (scripts/make_demo.py).
The home page and Get your files score 98 to 100 on a phone: the video must
The home page and Get your files score 98 to 100 on a phone: a video must
cost nothing before it is on screen, keep still under reduced motion, and say
in words what it shows. The files are committed, so their sizes are held
here, and the words come from what the recording measured.
here, and the words come from what each recording measured. Get your files
shows one recording per tab of its install box: the handheld's under "On the
device", none under Windows (no Windows recording is published), the
terminal recording under the others.
"""
from __future__ import annotations
@@ -29,43 +32,90 @@ COMPONENT = ROOT / "website" / "src" / "components" / "InstallDemo.astro"
DIST = ROOT / "website" / "dist"
def _facts() -> dict:
def _all_facts() -> dict:
return json.loads((DEMO / "demo.json").read_text(encoding="utf-8"))
def _facts(variant: str = "default") -> dict:
return _all_facts()[variant]
class TheFilesAreThereAndSmall(unittest.TestCase):
def test_each_output_is_under_its_target(self):
for name, limit in make_demo.LIMITS.items():
with self.subTest(name=name):
self.assertLessEqual((DEMO / name).stat().st_size, limit)
for variant in make_demo.VARIANTS.values():
for name, limit in make_demo.limits(variant).items():
with self.subTest(name=name):
self.assertLessEqual((DEMO / name).stat().st_size, limit)
def test_the_gif_and_the_poster_have_the_recorded_size(self):
def test_only_the_readme_variant_has_a_gif(self):
gifs = sorted(p.name for p in DEMO.glob("*.gif"))
self.assertEqual(gifs, [Path(generate_readme.DEMO_GIF).name])
self.assertEqual([v.name for v in make_demo.VARIANTS.values() if v.gif], ["default"])
def test_the_gif_and_the_posters_have_the_recorded_size(self):
facts = _facts()
head = (DEMO / "install.gif").read_bytes()[:10]
self.assertEqual(head[:6], b"GIF89a")
self.assertEqual(struct.unpack("<HH", head[6:10]), (facts["width"], facts["height"]))
png = (DEMO / "install.png").read_bytes()[:24]
self.assertEqual(png[:8], b"\x89PNG\r\n\x1a\n")
self.assertEqual(struct.unpack(">II", png[16:24]), (facts["width"], facts["height"]))
for variant in make_demo.VARIANTS.values():
facts = _facts(variant.name)
png = (DEMO / f"{variant.stem}.png").read_bytes()[:24]
with self.subTest(variant=variant.name):
self.assertEqual(png[:8], b"\x89PNG\r\n\x1a\n")
self.assertEqual(struct.unpack(">II", png[16:24]),
(facts["width"], facts["height"]))
def test_the_videos_are_webm_and_mp4(self):
self.assertEqual((DEMO / "install.webm").read_bytes()[:4], b"\x1a\x45\xdf\xa3")
self.assertEqual((DEMO / "install.mp4").read_bytes()[4:8], b"ftyp")
for variant in make_demo.VARIANTS.values():
with self.subTest(variant=variant.name):
self.assertEqual((DEMO / f"{variant.stem}.webm").read_bytes()[:4],
b"\x1a\x45\xdf\xa3")
self.assertEqual((DEMO / f"{variant.stem}.mp4").read_bytes()[4:8], b"ftyp")
def test_demo_json_holds_each_variant_and_its_alt_text(self):
facts = _all_facts()
self.assertEqual(list(facts), list(make_demo.VARIANTS))
for name, entry in facts.items():
with self.subTest(variant=name):
self.assertEqual(entry["alt"], make_demo.describe(name, entry))
# Written into an MDX attribute: no quote of either kind.
self.assertNotRegex(entry["alt"], "[\"']")
class TheTapeAndTheDriverAgree(unittest.TestCase):
def setUp(self):
self.tape = make_demo.TAPE.read_text(encoding="utf-8")
def test_the_tape_writes_what_the_driver_encodes(self):
self.assertIn(f"Output {make_demo.RECORDED.relative_to(ROOT).as_posix()}\n", self.tape)
self.assertIn(f"Screenshot {make_demo.POSTER_SHOT.relative_to(ROOT).as_posix()}\n",
self.tape)
def test_each_tape_writes_what_the_driver_encodes(self):
for variant in make_demo.VARIANTS.values():
tape = variant.tape.read_text(encoding="utf-8")
with self.subTest(variant=variant.name):
self.assertIn(f"Output {variant.recorded.relative_to(ROOT).as_posix()}\n", tape)
self.assertIn(
f"Screenshot {variant.poster_shot.relative_to(ROOT).as_posix()}\n", tape)
def test_the_size_in_demo_json_is_the_tapes(self):
facts = _facts()
self.assertIn(f"Set Width {facts['width']}\n", self.tape)
self.assertIn(f"Set Height {facts['height']}\n", self.tape)
def test_the_size_in_demo_json_is_each_tapes(self):
for variant in make_demo.VARIANTS.values():
facts, tape = _facts(variant.name), variant.tape.read_text(encoding="utf-8")
with self.subTest(variant=variant.name):
self.assertIn(f"Set Width {facts['width']}\n", tape)
self.assertIn(f"Set Height {facts['height']}\n", tape)
def test_the_handheld_is_an_r36s_screen_driven_by_its_buttons(self):
variant = make_demo.VARIANTS["handheld"]
tape = variant.tape.read_text(encoding="utf-8")
self.assertIn("Set Width 640\n", tape)
self.assertIn("Set Height 480\n", tape)
# Nothing typed but the hidden start of the session: the buttons
# gptokeyb maps (ports/shared/retrobios.gptk: a = enter) do the rest.
self.assertEqual(re.findall(r"^Type (.*)$", tape, re.M),
["'exec \"$RETROBIOS_DEMO_SESSION\"'"])
gptk = (ROOT / "ports" / "shared" / "retrobios.gptk").read_text(encoding="utf-8")
self.assertRegex(gptk, r"(?m)^a = enter$")
env = make_demo.session_env("http://127.0.0.1:1", ROOT / "tmp", ROOT / "tmp", variant)
self.assertEqual(env["RETROBIOS_PAD"], "gptokeyb")
self.assertEqual(env["RETROBIOS_OS"], "linux")
self.assertEqual(variant.mount, "/roms")
def test_the_command_typed_is_the_one_the_pages_give(self):
from siteguides import install_command # noqa: PLC0415
@@ -112,7 +162,13 @@ class TheVideoLoadsOnlyOnScreen(unittest.TestCase):
self.assertRegex(self.markup, rf"\n\t\t{attribute}\n")
def test_webm_comes_before_mp4(self):
self.assertLess(self.markup.index("install.webm"), self.markup.index("install.mp4"))
self.assertLess(self.markup.index("asset('webm')"), self.markup.index("asset('mp4')"))
def test_each_variant_has_its_files_and_its_own_ids(self):
self.assertIn("const stem = variant === 'default' ? 'install' : `install-${variant}`;",
self.source)
self.assertIn("const textId = name ? `rb-demo-text-${name}` : 'rb-demo-text';",
self.source)
def test_reduced_motion_keeps_the_poster_with_a_button(self):
self.assertIn("matchMedia('(prefers-reduced-motion: reduce)')", self.source)
@@ -121,44 +177,93 @@ class TheVideoLoadsOnlyOnScreen(unittest.TestCase):
self.markup)
def test_screen_readers_get_the_description(self):
self.assertIn('aria-describedby="rb-demo-text"', self.markup)
self.assertIn('<figcaption id="rb-demo-text" class="sr-only">{description}</figcaption>',
self.assertIn("aria-describedby={textId}", self.markup)
self.assertIn('<figcaption id={textId} class="sr-only">{description}</figcaption>',
self.markup)
def test_the_component_is_registered(self):
self.assertEqual(sitestarlight.COMPONENTS["InstallDemo"], "components/InstallDemo.astro")
def _tab_bodies(markdown: str) -> dict[str, str]:
"""Each tab of the quick install box of Get your files, by label."""
box = markdown[markdown.index("{ #quick-install }"):]
box = box[:box.index("</Tabs>")]
return dict(re.findall(r'<TabItem label="([^"]+)">(.*?)</TabItem>', box, re.S))
class TheTwoPagesShowIt(unittest.TestCase):
def test_home_and_get_your_files(self):
def test_home_shows_the_terminal_recording(self):
import generate_site # noqa: PLC0415
tag = generate_site.install_demo()[0]
self.assertIn(f'description="{generate_readme.demo_description()}"', tag)
source = Path(generate_site.__file__).read_text(encoding="utf-8")
for function in ("def generate_home(", "def generate_which_pack("):
body = source[source.index(function):]
body = body[:body.index("\ndef ", 1)]
with self.subTest(function=function):
self.assertIn("*install_demo(),", body)
body = source[source.index("def generate_home("):]
body = body[:body.index("\ndef ", 1)]
self.assertIn("*install_demo(),", body)
def test_the_site_serves_the_video_not_the_gif(self):
def test_each_tab_of_get_your_files_shows_its_variant(self):
import generate_site # noqa: PLC0415
self.assertEqual(generate_site.DEMO_ASSETS, ("install.webm", "install.mp4", "install.png"))
tabs = _tab_bodies(generate_site.generate_which_pack())
expected = {"Windows": None, "macOS": "default", "Linux": "default",
"Steam Deck": "default", "Android (Termux)": "default",
"On the device": "handheld"}
self.assertEqual(set(tabs), set(expected))
for label, variant in expected.items():
demos = re.findall(r"<InstallDemo [^>]*/>", tabs[label])
with self.subTest(tab=label):
if variant is None:
self.assertEqual(demos, [])
continue
self.assertEqual(len(demos), 1)
self.assertIn(f'description="{_facts(variant)["alt"]}"', demos[0])
self.assertIn(f'width="{_facts(variant)["width"]}"', demos[0])
self.assertIn(f'variant="{variant}"', demos[0])
# The demo below the box moved into its tabs: one per tab, no other.
page = generate_site.generate_which_pack()
self.assertEqual(page.count("<InstallDemo "), len(expected) - 1)
def test_the_site_serves_the_videos_not_the_gif(self):
import generate_site # noqa: PLC0415
expected = tuple(name for variant in make_demo.VARIANTS.values()
for name in variant.outputs if not name.endswith(".gif"))
self.assertEqual(sorted(generate_site.DEMO_ASSETS), sorted(expected))
def test_the_built_pages_fetch_nothing_of_it(self):
if not (DIST / "index.html").is_file():
self.skipTest("no built site")
for page in ("index.html", "which-pack/index.html"):
html = (DIST / page).read_text(encoding="utf-8")
figure = re.search(r'<figure class="rb-demo[^"]*".*?</figure>', html, re.S)
figures = re.findall(r'<figure class="rb-demo[^"]*".*?</figure>', html, re.S)
with self.subTest(page=page):
self.assertIsNotNone(figure)
self.assertIn('preload="none"', figure.group(0))
self.assertNotRegex(figure.group(0), r"\s(src|poster)=")
for name in ("install.webm", "install.mp4", "install.png"):
self.assertTrue((DIST / "assets" / "demo" / name).is_file())
self.assertTrue(figures)
for figure in figures:
self.assertIn('preload="none"', figure)
self.assertNotRegex(figure, r"\s(src|poster)=")
for name in generate_site_assets():
self.assertTrue((DIST / "assets" / "demo" / name).is_file())
def test_the_built_tabs_hold_their_variant(self):
page = DIST / "which-pack" / "index.html"
if not page.is_file():
self.skipTest("no built site")
html = page.read_text(encoding="utf-8")
panel = r'<div[^>]*role="tabpanel"'
panels = re.findall(rf"{panel}[^>]*>(.*?)(?={panel}|</starlight-tabs>)", html, re.S)
variants = [re.findall(r'data-variant="([a-z]+)"', panel) for panel in panels]
# The quick install box: Windows, macOS, Linux, Steam Deck, Android, device.
self.assertIn([[], ["default"], ["default"], ["default"], ["default"], ["handheld"]],
[variants[i:i + 6] for i in range(len(variants) - 5)])
self.assertIn("assets/demo/install-handheld.png", html)
def generate_site_assets() -> tuple[str, ...]:
import generate_site # noqa: PLC0415
return generate_site.DEMO_ASSETS
if __name__ == "__main__":
+94
View File
@@ -0,0 +1,94 @@
"""The drawn steps of the install box (scripts/sitesteps.py).
Each drawing says in words what it shows, to a screen reader and to a
reader whose images do not load: a title and a desc the SVG names. It tells
the same steps as the text above it, the keys included, so the picture and
the words never disagree. And it draws in the theme's colours.
"""
from __future__ import annotations
import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
import re
import sys
import unittest
import xml.etree.ElementTree as ET
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))
import sitesteps # noqa: E402
from siteguides import TERMINALS # noqa: E402
SVG = "{http://www.w3.org/2000/svg}"
DIST = ROOT / "website" / "dist"
class EachDrawingNamesItsSteps(unittest.TestCase):
def test_title_and_desc_are_what_aria_points_at(self):
for tab in sitesteps.STEPS:
for layout in ("wide", "narrow"):
root = ET.fromstring(sitesteps._svg(tab, layout))
with self.subTest(tab=tab, layout=layout):
self.assertEqual(root.get("role"), "img")
title, desc = root.find(f"{SVG}title"), root.find(f"{SVG}desc")
self.assertIsNotNone(title)
self.assertIsNotNone(desc)
self.assertEqual(root.get("aria-labelledby").split(),
[title.get("id"), desc.get("id")])
self.assertEqual(desc.text, sitesteps.description(tab))
def test_every_caption_drawn_is_in_the_desc(self):
for tab, (_title, steps) in sitesteps.STEPS.items():
desc = sitesteps.description(tab)
for step in steps:
with self.subTest(tab=tab, step=step.caption):
self.assertIn(step.caption, desc)
def test_the_drawing_and_the_text_name_the_same_keys(self):
for tab, (_title, steps) in sitesteps.STEPS.items():
text = TERMINALS[tab][1].replace("**", "")
keys = {step.word for step in steps if step.icon == "keys"}
with self.subTest(tab=tab):
for word in keys:
self.assertIn(word, text)
drawn = set(re.findall(r"\b(?:Ctrl|Cmd|Shift|Alt)\+\w+(?:\+\w+)?", text))
self.assertLessEqual(drawn, keys)
self.assertIn("Click Copy", text)
def test_one_drawing_per_computer_tab_none_elsewhere(self):
self.assertEqual(set(sitesteps.STEPS), {"windows", "macos", "linux", "steamdeck"})
self.assertEqual(sitesteps.steps_figure("device"), [])
def test_colours_come_from_the_theme(self):
for tab in sitesteps.STEPS:
root = ET.fromstring(sitesteps._svg(tab, "wide"))
for node in root.iter():
tag = node.tag.removeprefix(SVG)
if tag in ("rect", "path", "circle"):
with self.subTest(tab=tab, node=ET.tostring(node)[:60]):
self.assertTrue(node.get("class", "").startswith("rb-d-"))
def test_ids_are_unique_on_the_page(self):
ids = re.findall(r'id="([^"]+)"', "".join(
sitesteps._svg(tab, layout) for tab in sitesteps.STEPS for layout in ("wide", "narrow")))
self.assertEqual(len(ids), len(set(ids)))
class TheBuiltPageHoldsThem(unittest.TestCase):
def test_under_each_tab_of_get_your_files(self):
page = DIST / "which-pack" / "index.html"
if not page.is_file():
self.skipTest("no built site")
html = page.read_text(encoding="utf-8")
for tab in sitesteps.STEPS:
with self.subTest(tab=tab):
self.assertIn(f'aria-labelledby="rb-steps-{tab}-wide-t rb-steps-{tab}-wide-d"',
html)
self.assertIn(f'<desc id="rb-steps-{tab}-narrow-d">', html)
if __name__ == "__main__":
unittest.main()
+19 -8
View File
@@ -4,18 +4,25 @@
// poster and the sources are set by the script below once the video is in
// view (preload="none", no src in the HTML). With reduced motion or Save-Data
// the poster stays, and the button plays it. The button pauses it otherwise.
// variant names the recording (default, handheld), name the tab a demo sits
// in when a page holds several: a demo in a closed tab is not on screen, so
// it loads nothing until its tab is chosen.
interface Props {
description: string;
width: string;
height: string;
variant?: 'default' | 'handheld';
name?: string;
}
const { description, width, height } = Astro.props;
const { description, width, height, variant = 'default', name = '' } = Astro.props;
const base = import.meta.env.BASE_URL.replace(/\/?$/, '/');
const asset = (name: string) => `${base}assets/demo/${name}`;
const stem = variant === 'default' ? 'install' : `install-${variant}`;
const asset = (ext: string) => `${base}assets/demo/${stem}.${ext}`;
const textId = name ? `rb-demo-text-${name}` : 'rb-demo-text';
---
<figure class="rb-demo" data-demo data-pagefind-ignore>
<figure class="rb-demo" data-demo data-variant={variant} data-pagefind-ignore>
<video
width={width}
height={height}
@@ -24,14 +31,14 @@ const asset = (name: string) => `${base}assets/demo/${name}`;
playsinline
preload="none"
disablepictureinpicture
data-poster={asset('install.png')}
aria-describedby="rb-demo-text"
data-poster={asset('png')}
aria-describedby={textId}
>
<source data-src={asset('install.webm')} type="video/webm" />
<source data-src={asset('install.mp4')} type="video/mp4" />
<source data-src={asset('webm')} type="video/webm" />
<source data-src={asset('mp4')} type="video/mp4" />
</video>
<button type="button" class="rb-demo-toggle" hidden>Play</button>
<figcaption id="rb-demo-text" class="sr-only">{description}</figcaption>
<figcaption id={textId} class="sr-only">{description}</figcaption>
</figure>
<script>
@@ -91,6 +98,10 @@ const asset = (name: string) => `${base}assets/demo/${name}`;
border: 1px solid var(--rb-border);
border-radius: 0.5rem;
}
/* A handheld's 4:3 screen, no taller than the terminal recording. */
.rb-demo[data-variant='handheld'] {
max-inline-size: 32rem;
}
.rb-demo-toggle {
position: absolute;
inset-inline-end: 0.5rem;
+1 -1
View File
@@ -69,7 +69,7 @@ Every module in `tests/`, by the area it guards.
| Pack probe | the cores a platform runs for a system, staging grown to a fixed point, runs mapped to pack and manifest entries, the line and its schema, the probe lock per run, that no page shows its lines. One class builds a real pack narrowed to PlayStation and lays it out both ways, from the archive and with `install.py --dest`, and skips without the collection | `test_pack_probe` |
| Automation runner, disk cleanup, asset sync and bit-flip repair | fake jobs, git repositories with a bare origin and session worktrees, a committed file with one bit flipped, canned CI runs in place of `gh` | `test_autorun` |
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_retired_files`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_agents`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_demo`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_inputs`, `test_site_insights`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_privacy`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` |
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_retired_files`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_agents`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_demo`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_inputs`, `test_site_insights`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_privacy`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_steps`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` |
| Games on the card | small DATs, listxml and setup sources, a fake fetcher, fixture install lists, and the committed data against its schemas | `test_arcade_parents`, `test_game_catalogs`, `test_game_headers`, `test_game_regions`, `test_games_index`, `test_romfolders_card`, `test_romfolders_es` |
## Test architecture
+1 -1
View File
@@ -1374,7 +1374,7 @@ before a release (wiki/release-process.md). Its lines stay in
| `sitesources.py` | Record which cited source paths each pinned upstream tree holds, for the site's source links (`--fetch` reads a tree no cache holds) |
| `site_markdown.py` | Write each built page as Markdown beside its HTML (`<page>/index.md`, named in every page's head) and `llms-full.txt`, the guides and every setup's page in one file, for the assistants that read the site. The Astro build runs it once the pages are written |
| `site_insights.py` | What readers look for and do not find, from totals only: searches that show the site but are rarely clicked, file names searched and whether the collection holds them, site searches that found nothing, broken addresses, "Did this page help?" answers, GitHub traffic and release downloads (`--days`, `--skip gsc,goatcounter,github`, `--output`). Reads Search Console, GoatCounter and GitHub with credentials kept outside the repository. [What the site counts](privacy.md) |
| `make_demo.py` | Record the installer demo of the README and the site into `docs_assets/demo/`: an ArkOS card with six games built under `tmp/demo/` and mounted in a mount namespace of the user, this checkout served on loopback, `docs_assets/demo/install.tape` run by VHS (needs `vhs`, `ttyd`, `ffmpeg`, `unshare`), then the GIF, WebM, MP4 and poster encoded and held to their size targets. `demo.json` records what the recording shows, for the alt text. Never run by the build |
| `make_demo.py` | Record the installer demos of the README and the site into `docs_assets/demo/`. An ArkOS card with six games is built under `tmp/demo/` in a mount namespace of the user, this checkout is served on loopback, and VHS runs one tape per variant: `default` (`install.tape`, the one-line command in a terminal, the card at `/media/EASYROMS`, needs `unshare`) and `handheld` (`install-handheld.tape`, the full-screen interface at 640x480 on an ArkOS root made with `bwrap`, as its Tools menu starts it, driven by the keys gptokeyb sends for the buttons). `--variant NAME` records one (repeatable), every variant by default, the others kept. Each needs `vhs`, `ttyd` and `ffmpeg`. The default gets the README's GIF, each gets a WebM, an MP4 and a poster, held to their size targets, and an entry in `demo.json` with what it shows and its alt text. No Windows variant: under pwsh on Linux the installer's summary gives `--dest /mnt/e/bios` back. Never run by the build |
| `validate_site.py` | Validate rendered metadata, heading order, image alternatives, JSON-LD, local resources, links and fragments, and the page size and search index budgets |
| `romset_recipes.py` | Identify which emulator version an arcade archive matches, rebuild a pinned archive from ROMs already held, record a recipe for every archive the collection holds and build any of them from the members it holds (see [Archive recipes](#archive-recipes)) |
| `archive_build.py` | The archive recipes behind `romset_recipes.py --capture`, `--build` and `--build-all`: capture, member lookup by sha1, verified build |