fix(web): a newly installed plugin shows up on its own, and the display form can be used without a mouse

Installing a plugin left the sidebar unchanged until a reload. The reason is
timing, not caching: the host restarts the scripting runner AFTER the job reports
done, and the plugin only registers its UI once that comes back — several seconds
later, by which point the one-shot invalidation had already run and found the old
list. The nav then waited out the 30 s idle poll, which in practice meant "until
I reloaded". Anything that changes the installed set now switches the directory
to a 2 s poll for a minute, so the entry lands about a second after the plugin
actually comes up. Measured end to end in a browser: 29 s → 7 s, with the plugin
registering at 6 s.

The plugin entries also never animated. They are rendered outside the `motion.nav`
that carries the variants and the stagger, so they inherited neither and simply
appeared — most visibly in exactly the case above, where one shows up in a nav
that is already on screen. They get their own animation container now, matching
the main nav. (A motion-wrapped div around the link, not `motion(Link)`, which
erases TanStack's typed `params`.)

The accessibility pass on the display form, where the console's densest controls
live:

- The Custom block's numeric inputs had a `<label>` with no `htmlFor` next to an
  `<input>` with no `id`, which labels nothing at all — a screen reader announced
  them as unnamed spin buttons. Single controls are paired properly now; the
  button groups became real `<fieldset>`/`<legend>`, which is what they are.
- Every option group signalled its active choice with fill colour alone. They
  carry `aria-pressed` now, so the state is available to assistive tech and not
  only to people who can compare two button variants.
- `QueryState`'s error branch is a live region, so a query that fails announces
  the failure instead of silently swapping one region for another.
- Motion honours `prefers-reduced-motion` instead of overriding it.
- `<html lang>` follows the locale instead of claiming "en" while the app renders
  German. Verified: switching to de flips the attribute.
- "Close menu", "Language" and "Loading" went through the message catalogue.

Also: ten dead message keys removed (a whole removed Clients page and the old
Settings token field), and the README no longer tells operators to set the
management token under "Settings → API token" — that field is gone and the token
has been server-side only for some time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 00:20:10 +02:00
co-authored by Claude Opus 5
parent 9e505aba41
commit de17ceb8f8
11 changed files with 176 additions and 75 deletions
+4 -3
View File
@@ -35,8 +35,9 @@ WAYLAND_DISPLAY=wayland-kde XDG_CURRENT_DESKTOP=KDE \
# loopback :47990, no token (a token is mandatory for non-loopback binds).
```
If the host runs with `--mgmt-token`, set it under **Settings → API token** (stored in
`localStorage`, sent as `Authorization: Bearer …` by the orval fetcher).
The management token is **server-side only** — set `PUNKTFUNK_MGMT_TOKEN` in the console's
environment and the BFF injects it when proxying (`server/routes/api/[...].ts`). It never reaches
the browser, so there is no token field in the UI; the browser only ever holds the session cookie.
## Build & run (Nitro + Bun)
@@ -115,7 +116,7 @@ src/
app-shell.tsx sidebar nav (brand lens + wordmark) + language switcher
brand-mark/wordmark/logo.tsx punktfunk lens mark + wordmark (shared with the site/docs)
ui/ @unom/ui-backed primitives (button, input, label, card; badge/table/skeleton)
query-state.tsx loading/error wrapper (incl. 401 → "set a token")
query-state.tsx loading/error wrapper (401 → the session is gone, re-login)
api/
fetcher.ts orval mutator: base URL, bearer token, JSON, throwing ApiError
gen/ GENERATED react-query hooks + models (orval)
+1 -10
View File
@@ -6,7 +6,6 @@
"nav_dashboard": "Übersicht",
"nav_host": "Host",
"nav_displays": "Virtuelle Anzeigen",
"nav_clients": "Gekoppelte Geräte",
"nav_pairing": "Kopplung",
"nav_library": "Bibliothek",
"nav_plugins": "Plugins",
@@ -56,6 +55,7 @@
"action_stop_failed": "Die Sitzung konnte nicht beendet werden.",
"action_idr_failed": "Es konnte kein Keyframe angefordert werden.",
"nav_settings": "Einstellungen",
"nav_close_menu": "Menü schließen",
"nav_more": "Mehr",
"status_title": "Live-Status",
"status_video": "Video",
@@ -103,7 +103,6 @@
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} bindet einen {vendor}-Encoder, aber die GPU der nächsten Sitzung ist „{name}“ — die veraltete Bindung sollte aus host.env entfernt werden.",
"host_conflicts_title": "Auf diesem Rechner läuft ein weiterer Game-Streaming-Server",
"host_conflicts_help": "Er belegt dieselben Ports wie Punktfunk — es antwortet also der Server, der zuerst gestartet ist. Das ist meist der Grund, warum sich ein scheinbar funktionierender Host nicht verbinden lässt. Beende oder deinstalliere den anderen Server und starte Punktfunk neu.",
"host_displays": "Virtuelle Displays",
"host_displays_help": "Wie virtuelle Displays erstellt, aktiv gehalten und angeordnet werden. Wähle eine Voreinstellung oder „Benutzerdefiniert“, um Optionen direkt zu setzen. Eine Änderung gilt ab der nächsten Sitzung.",
"display_config_title": "Konfiguration",
"display_preset": "Voreinstellung",
@@ -187,11 +186,8 @@
"display_all_saved": "Alle Änderungen gespeichert",
"display_revert": "Änderungen verwerfen",
"display_discard_confirm": "Du hast nicht gespeicherte eigene Einstellungen. Verwerfen?",
"clients_title": "Gekoppelte Geräte",
"clients_empty": "Noch keine gekoppelten Geräte.",
"clients_name": "Name",
"clients_fingerprint": "Fingerabdruck",
"clients_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
"pairing_title": "Kopplung",
"pairing_idle": "Keine Kopplung aktiv. Starte die Kopplung in einem Moonlight-Client und gib hier die PIN ein.",
"pairing_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:",
@@ -260,15 +256,10 @@
"library_delete": "Löschen",
"library_delete_confirm": "Dieses eigene Spiel löschen? Das kann nicht rückgängig gemacht werden.",
"settings_title": "Einstellungen",
"settings_token_label": "API-Token",
"settings_token_help": "Bearer-Token für die Verwaltungs-API. Bei einem Loopback-Host ohne Token leer lassen.",
"settings_language": "Sprache",
"settings_save": "Speichern",
"settings_saved": "Gespeichert.",
"common_loading": "Wird geladen…",
"common_error": "Etwas ist schiefgelaufen.",
"common_retry": "Erneut versuchen",
"common_yes": "Ja",
"common_cancel": "Abbrechen",
"common_unauthorized": "Sitzung abgelaufen — Weiterleitung zur Anmeldung…",
"login_title": "Anmelden",
+1 -10
View File
@@ -6,7 +6,6 @@
"nav_dashboard": "Dashboard",
"nav_host": "Host",
"nav_displays": "Virtual displays",
"nav_clients": "Paired clients",
"nav_pairing": "Pairing",
"nav_library": "Library",
"nav_automation": "Automation",
@@ -51,6 +50,7 @@
"action_stop_failed": "Could not stop the session.",
"action_idr_failed": "Could not request a keyframe.",
"nav_settings": "Settings",
"nav_close_menu": "Close menu",
"nav_more": "More",
"nav_plugins": "Plugins",
"plugin_offline_title": "This plugin isn't running",
@@ -103,7 +103,6 @@
"gpu_encoder_pin_warning": "PUNKTFUNK_ENCODER={value} pins a {vendor} encoder, but the next session's GPU is “{name}” — remove the stale pin from host.env.",
"host_conflicts_title": "Another game-streaming server is running on this machine",
"host_conflicts_help": "It listens on the same ports as punktfunk, so whichever one started first answers your clients — which is usually why a working-looking host cannot be connected to. Stop or uninstall the other server, then restart punktfunk.",
"host_displays": "Virtual displays",
"host_displays_help": "How virtual displays are created, kept alive, and arranged. Pick a preset, or choose Custom to set options directly. A change applies to the next session.",
"display_config_title": "Configuration",
"display_preset": "Preset",
@@ -187,11 +186,8 @@
"display_all_saved": "All changes saved",
"display_revert": "Discard changes",
"display_discard_confirm": "You have unsaved custom settings. Discard them?",
"clients_title": "Paired clients",
"clients_empty": "No paired clients yet.",
"clients_name": "Name",
"clients_fingerprint": "Fingerprint",
"clients_unpair_confirm": "Unpair this client? It will need to pair again to connect.",
"pairing_title": "Pairing",
"pairing_idle": "No pairing in progress. Start pairing from a Moonlight client, then enter its PIN here.",
"pairing_waiting": "A client is waiting to pair. Enter the PIN it shows:",
@@ -260,15 +256,10 @@
"library_delete": "Delete",
"library_delete_confirm": "Delete this custom game? This can't be undone.",
"settings_title": "Settings",
"settings_token_label": "API token",
"settings_token_help": "Bearer token for the management API. Leave empty for a loopback host with no token.",
"settings_language": "Language",
"settings_save": "Save",
"settings_saved": "Saved.",
"common_loading": "Loading…",
"common_error": "Something went wrong.",
"common_retry": "Retry",
"common_yes": "Yes",
"common_cancel": "Cancel",
"common_unauthorized": "Session expired — redirecting to sign in…",
"login_title": "Sign in",
+6 -1
View File
@@ -28,6 +28,7 @@ import { getGetLibraryQueryKey } from "@/api/gen/library/library";
import { getListNativeClientsQueryKey } from "@/api/gen/native/native";
import { getGetPairingStatusQueryKey } from "@/api/gen/pairing/pairing";
import { getGetUpdateStatusQueryKey } from "@/api/gen/update/update";
import { boostPluginPolling, PLUGINS_KEY } from "@/api/plugins";
import { storeKeys } from "@/api/store";
/** Which query keys a given event kind invalidates. Unknown kinds are ignored on purpose.
@@ -72,7 +73,7 @@ function keysFor(kind: string): readonly (readonly unknown[])[] {
case "plugins.changed":
case "store.changed":
return [
["plugins"],
PLUGINS_KEY,
storeKeys.catalog,
storeKeys.installed,
storeKeys.runtime,
@@ -154,6 +155,10 @@ function attach(): void {
for (const kind of KINDS) {
source.addEventListener(kind, () => {
if (!client) return;
// The installed set changed — but the runner is probably still restarting, so keep
// checking for a while rather than trusting this one refetch (see boostPluginPolling).
if (kind === "plugins.changed" || kind === "store.changed")
boostPluginPolling();
for (const key of keysFor(kind)) invalidate(client, key);
// `host.started` names no keys — the host is NEW, so everything we hold predates it.
if (kind === "host.started") resyncAll(client);
+29 -2
View File
@@ -60,12 +60,39 @@ export const pluginIcon = (name?: string): LucideIcon => {
return ICONS[name] ?? Puzzle;
};
/** The query key for the plugin directory — the nav is built from it. */
export const PLUGINS_KEY = ["plugins"] as const;
const IDLE_POLL_MS = 30_000;
const BOOST_POLL_MS = 2_000;
/** How long to keep polling fast after something changed the installed set. */
const BOOST_MS = 60_000;
/**
* Until this timestamp, poll the directory fast.
*
* A finished install is NOT the moment the plugin appears: the host restarts the scripting runner
* afterwards, and the plugin only registers its UI once that comes back — several seconds later,
* and after any one-shot invalidation has already run and found the old list. So the nav sat
* unchanged until the 30 s idle poll happened to land, which in practice meant "until I reloaded".
*
* Module-level rather than component state because the two things that need to trigger it (a store
* job settling, a `plugins.changed`/`store.changed` event) both live outside the nav.
*/
let boostUntil = 0;
/** Poll the plugin directory fast for a while — call after anything that changes what's installed. */
export function boostPluginPolling(): void {
boostUntil = Date.now() + BOOST_MS;
}
/** Live plugin registrations, polled (and refetched on window focus) so the nav stays current. */
export function usePlugins() {
return useQuery({
queryKey: ["plugins"],
queryKey: PLUGINS_KEY,
queryFn: () => apiFetch<PluginSummary[]>("/api/v1/plugins"),
refetchInterval: 30_000,
refetchInterval: () =>
Date.now() < boostUntil ? BOOST_POLL_MS : IDLE_POLL_MS,
refetchOnWindowFocus: true,
});
}
+4
View File
@@ -11,6 +11,7 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import { apiFetch } from "@/api/fetcher";
import { boostPluginPolling } from "@/api/plugins";
/**
* How much a plugin's provenance is worth, from most to least trustworthy:
@@ -166,6 +167,9 @@ const json = (method: string, body: unknown): RequestInit => ({
* installed list, the runner state (it restarts), and the plugin directory the nav is built from.
*/
export function invalidateStore(qc: QueryClient): Promise<void> {
// The runner restarts AFTER the job reports done, so the plugin registers its UI a few seconds
// from now — this invalidation would otherwise refetch the pre-install list and stop looking.
boostPluginPolling();
return Promise.all([
qc.invalidateQueries({ queryKey: storeKeys.catalog }),
qc.invalidateQueries({ queryKey: storeKeys.installed }),
+44 -19
View File
@@ -143,32 +143,57 @@ function PluginNavSection() {
const plugins = uiPlugins(data);
if (plugins.length === 0) return null;
return (
<div className="mt-6 flex flex-col gap-1">
<p className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
// Its own animation container, with the same variants + stagger as the main nav above. These
// were plain links: the group sits OUTSIDE that `motion.nav`, so it inherited neither the
// stagger nor the variants and plugin entries simply appeared. They arrive asynchronously
// (and a fresh install adds one to a nav that is already on screen), which is exactly when
// the animation earns its keep.
<motion.div
animate="enter"
initial="from"
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="mt-6 flex flex-col gap-1"
>
<motion.p
variants={{ from: { opacity: 0 }, enter: { opacity: 1 } }}
className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground/70"
>
{m.nav_plugins()}
</p>
</motion.p>
{plugins.map((p) => {
const Icon = pluginIcon(p.ui?.icon);
return (
<Link
// The motion wrapper is a DIV around the link, not `motion(Link)`: wrapping Link
// erases TanStack's typed `params`, and these entries need `$pluginId`.
<motion.div
key={p.id}
to="/plugins/$pluginId/$"
params={{ pluginId: p.id, _splat: "" }}
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
activeProps={{
className: "bg-primary/15 text-foreground font-medium",
variants={{
from: { opacity: 0, x: -20 },
enter: { opacity: 1, x: 0 },
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-md bg-primary/0 transition-colors duration-200 group-hover:bg-primary/15"
/>
<Icon className="relative size-4" />
<span className="relative truncate">{p.title}</span>
</Link>
<Link
to="/plugins/$pluginId/$"
params={{ pluginId: p.id, _splat: "" }}
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
activeProps={{
className: "bg-primary/15 text-foreground font-medium",
}}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-md bg-primary/0 transition-colors duration-200 group-hover:bg-primary/15"
/>
<Icon className="relative size-4" />
<span className="relative truncate">{p.title}</span>
</Link>
</motion.div>
);
})}
</div>
</motion.div>
);
}
@@ -196,7 +221,7 @@ function MobileNav() {
{moreOpen && (
<button
type="button"
aria-label="Close menu"
aria-label={m.nav_close_menu()}
className="fixed inset-0 z-40 bg-black/40 sm:hidden"
onClick={() => setMoreOpen(false)}
/>
@@ -277,7 +302,7 @@ function LanguageSwitcher() {
const current = useLocale();
return (
// biome-ignore lint/a11y/useSemanticElements: an aria-labelled role="group" is the right pattern for this small control cluster — no single semantic element fits.
<div className="flex gap-1" role="group" aria-label="Language">
<div className="flex gap-1" role="group" aria-label={m.settings_language()}>
{locales.map((l: Locale) => (
<button
key={l}
+7 -1
View File
@@ -32,7 +32,13 @@ export function QueryState({
if (error) {
const unauthorized = error instanceof ApiError && error.status === 401;
return (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
// `role="alert"` so the failure is announced. The loading branch above already has
// role="status"; without this, a query that resolved into an error swapped one silent
// region for another and a screen-reader user was told only that loading had stopped.
<div
role="alert"
className="rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm"
>
<p className="font-medium text-destructive">
{unauthorized ? m.common_unauthorized() : m.common_error()}
</p>
+2 -1
View File
@@ -1,6 +1,7 @@
import { motion, useReducedMotion, useTime, useTransform } from "motion/react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
// The punktfunk lens, alive. The two overlapping circles of the brand mark are
// recreated from divs and animated as if orbiting on a path whose long axis points
@@ -76,7 +77,7 @@ export function Spinner({
<div
ref={ref}
role="status"
aria-label="Loading"
aria-label={m.common_loading()}
className={cn("relative inline-block size-6 isolate", className)}
{...props}
>
+18 -8
View File
@@ -10,9 +10,10 @@ import {
} from "@tanstack/react-router";
import "@fontsource-variable/geist";
import { Toaster } from "@unom/ui/toast";
import { MotionConfig } from "motion/react";
import { useEffect } from "react";
import { AppShell } from "@/components/app-shell";
import { adoptStoredLocale } from "@/lib/i18n";
import { adoptStoredLocale, useLocale } from "@/lib/i18n";
import appCss from "@/styles.css?url";
export interface RouterContext {
@@ -41,23 +42,32 @@ function RootComponent() {
useEffect(() => {
adoptStoredLocale();
}, []);
// `lang` must track the locale the page is actually rendered in — it is what tells a screen
// reader which pronunciation to use, and it was pinned to "en" while the app switched to German
// underneath it. `adoptStoredLocale` also sets it on the live document; this keeps SSR honest.
const locale = useLocale();
// The login screen renders bare (no sidebar); everything else gets the app shell.
const isLogin = useRouterState({
select: (s) => s.location.pathname === "/login",
});
return (
<html lang="en" className="dark">
<html lang={locale} className="dark">
<head>
<HeadContent />
</head>
<body className="min-h-screen">
{isLogin ? (
<Outlet />
) : (
<AppShell>
{/* Motion defaults to `reducedMotion: "never"`, so every card, nav item and button
animated at full strength even for someone whose OS asks for less. "user" honours
the OS setting. */}
<MotionConfig reducedMotion="user">
{isLogin ? (
<Outlet />
</AppShell>
)}
) : (
<AppShell>
<Outlet />
</AppShell>
)}
</MotionConfig>
{/* Sonner toaster (lazy client-side) — success feedback for auto-saved settings. */}
<Toaster />
<Scripts />
+60 -20
View File
@@ -537,11 +537,13 @@ const DisplayForm: FC<{
<Field
label={m.display_keep_alive()}
help={m.display_keep_alive_help()}
group
>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant={ka.mode === "off" ? "default" : "outline"}
aria-pressed={ka.mode === "off"}
disabled={busy}
onClick={() =>
setDraft({ ...draft, keep_alive: { mode: "off" } })
@@ -552,6 +554,7 @@ const DisplayForm: FC<{
<Button
size="sm"
variant={ka.mode === "duration" ? "default" : "outline"}
aria-pressed={ka.mode === "duration"}
disabled={busy}
onClick={() =>
setDraft({
@@ -565,6 +568,7 @@ const DisplayForm: FC<{
<Button
size="sm"
variant={ka.mode === "forever" ? "default" : "outline"}
aria-pressed={ka.mode === "forever"}
disabled={busy}
onClick={() =>
setDraft({ ...draft, keep_alive: { mode: "forever" } })
@@ -575,6 +579,8 @@ const DisplayForm: FC<{
{ka.mode === "duration" && (
<div className="flex items-center gap-2">
<Input
id="display-keep-alive-seconds"
aria-label={m.display_keep_alive_seconds()}
type="number"
min={0}
className="w-24"
@@ -644,8 +650,9 @@ const DisplayForm: FC<{
}
/>
<Field label={m.display_max()}>
<Field label={m.display_max()} htmlFor="display-max">
<Input
id="display-max"
type="number"
min={1}
max={16}
@@ -773,19 +780,46 @@ const DisplayForm: FC<{
/** A labeled config field — label, then the control, then optional help. The single source of the
* label→control→help spacing so every field (keep-alive, the button groups, max-displays) lines up. */
const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({
label,
help,
children,
}) => (
<div className="space-y-3">
<Label className="block">{label}</Label>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</div>
);
const Field: FC<{
label: string;
help?: string;
children: ReactNode;
/** The id of the single control this labels, when there is one — see below. */
htmlFor?: string;
/** Set when the field wraps a GROUP of controls rather than one input. */
group?: boolean;
}> = ({ label, help, children, htmlFor, group }) => {
const helpId = help && htmlFor ? `${htmlFor}-help` : undefined;
const helpText = help && (
<p id={helpId} className="max-w-prose text-xs text-muted-foreground">
{help}
</p>
);
// A set of related buttons IS a fieldset, so say so with the element rather than an ARIA role.
// (The single-control case keeps a plain <label for>, which is the right pairing there.)
if (group) {
return (
<fieldset className="space-y-3">
<legend className="mb-3 block text-sm font-medium leading-none">
{label}
</legend>
{children}
{helpText}
</fieldset>
);
}
// A bare <Label> with no `htmlFor` next to an <input> with no `id` labels nothing at all: a
// screen reader announced these as unnamed spin buttons.
return (
<div className="space-y-3">
<Label className="block" htmlFor={htmlFor}>
{label}
</Label>
{children}
{helpText}
</div>
);
};
/**
* An Experimental-badged on/off policy toggle (the DDC/CI and PnP monitor axes) — rendered outside
@@ -801,19 +835,21 @@ const ExperimentalToggle: FC<{
onSet: (v: boolean) => void;
}> = ({ label, help, value, offLabel, onLabel, busy, onSet }) => (
<div className="border-t pt-4">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Label className="block">{label}</Label>
{/* A labelled group: the pair of buttons is one control, and the label belongs to both. */}
<fieldset className="space-y-3">
<legend className="mb-3 flex items-center gap-2 text-sm font-medium leading-none">
{label}
<Badge variant="outline" className="text-amber-600 dark:text-amber-500">
{m.display_experimental()}
</Badge>
</div>
</legend>
<div className="flex flex-wrap gap-2">
{([false, true] as const).map((on) => (
<Button
key={String(on)}
size="sm"
variant={value === on ? "default" : "outline"}
aria-pressed={value === on}
disabled={busy}
onClick={() => onSet(on)}
>
@@ -822,7 +858,7 @@ const ExperimentalToggle: FC<{
))}
</div>
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
</div>
</fieldset>
</div>
);
@@ -836,13 +872,17 @@ const Choice: FC<{
disabled: boolean;
onPick: (v: string) => void;
}> = ({ label, help, value, options, labels, disabled, onPick }) => (
<Field label={label} help={help}>
<Field label={label} help={help} group>
<div className="flex flex-wrap gap-2">
{options.map((o) => (
<Button
key={o}
size="sm"
variant={value === o ? "default" : "outline"}
// Which option is active was signalled by fill colour alone — invisible to a screen
// reader, and to anyone who can't separate the two variants. `aria-pressed` states it.
// (The sibling Choice in SessionGameCard already did this; these did not.)
aria-pressed={value === o}
disabled={disabled}
onClick={() => onPick(o)}
>