fix(web): close the last two high-severity items from the closeout audit

**The streamed-screen pin could still be clobbered by three other write paths.**
Deferring it to the server's value on Save fixed the reported sequence but not
the general case: the draft is only re-seeded while it is CLEAN, so once there is
an unsaved edit its `capture_monitor` is frozen at whatever it was before the
operator used the picker — and `applyAxis` (which spreads the last saved policy),
the built-in preset switch and the custom-preset apply all put that stale value
back. Every write path reads `serverCaptureMonitor()` now; no path spreads the
draft's copy.

**The session⇄game grace input had no accessible name.** That card has its own
`Field` and only DisplayCard's was fixed, so the number input was still announced
as an unnamed spin button. Same treatment: `htmlFor`/`id` for the single control,
`fieldset`/`legend` for the two button groups. Verified in a browser — zero
inputs without an accessible name across the Displays page.

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 f2e1b9872c
commit f66de3eba4
2 changed files with 60 additions and 19 deletions
+17 -6
View File
@@ -106,9 +106,15 @@ export const DisplaySection: FC = () => {
* changed the streamed screen still carried the OLD value and Save quietly put it back. Defer
* that one axis to whatever the server currently reports.
*/
/** The streamed-screen pin as the HOST currently has it. Every write path defers to this rather
* than to the draft: the draft is only re-seeded while it is CLEAN, so once the operator has an
* unsaved edit its `capture_monitor` is frozen at whatever it was before they used the picker
* below — and any write that spreads the draft would put the old pin back. */
const serverCaptureMonitor = () => q.data?.settings.capture_monitor ?? null;
const saveDraft = () => {
if (!draft) return;
apply({ ...draft, capture_monitor: q.data?.settings.capture_monitor });
apply({ ...draft, capture_monitor: serverCaptureMonitor() });
};
/**
@@ -127,7 +133,7 @@ export const DisplaySection: FC = () => {
// Reflect the flip straight away, keeping every other unsaved edit intact.
setDraft((d) => (d ? { ...d, ...patch } : d));
save.mutate(
{ data: { ...base, ...patch } },
{ data: { ...base, capture_monitor: serverCaptureMonitor(), ...patch } },
{
onSuccess: (res) => {
seeded.current = res.settings;
@@ -202,6 +208,7 @@ export const DisplaySection: FC = () => {
presets={q.data.presets}
customPresets={q.data.custom_presets}
serverEffective={q.data.effective}
serverCaptureMonitor={serverCaptureMonitor}
apply={apply}
applyAxis={applyAxis}
saveDraft={saveDraft}
@@ -243,6 +250,8 @@ const DisplayForm: FC<{
customPresets: CustomPreset[];
/** What the host reports as IN FORCE right now — not derived from the local draft. */
serverEffective: EffectivePolicy;
/** The streamed-screen pin as the host has it — the draft's copy goes stale while dirty. */
serverCaptureMonitor: () => string | null;
apply: (p: DisplayPolicy) => void;
/** Apply one orthogonal axis on top of the SAVED policy — never the unsaved draft. */
applyAxis: (patch: Partial<DisplayPolicy>) => void;
@@ -260,6 +269,7 @@ const DisplayForm: FC<{
presets,
customPresets,
serverEffective,
serverCaptureMonitor,
apply,
applyAxis,
saveDraft,
@@ -324,8 +334,8 @@ const DisplayForm: FC<{
pnp_disable_monitors: draft.pnp_disable_monitors ?? false,
// Which screen we stream is not a display-behavior axis at all — swapping the
// streamed screen out from under the operator because they changed a preset would be
// the worst kind of surprise.
capture_monitor: draft.capture_monitor ?? null,
// the worst kind of surprise. From the SERVER, not the draft (see serverCaptureMonitor).
capture_monitor: serverCaptureMonitor(),
});
} else {
apply({ ...draft, preset: id as Preset });
@@ -347,8 +357,9 @@ const DisplayForm: FC<{
// Nor is the streamed screen: this builds a FRESH policy object rather than spreading
// the draft, so anything not named here is silently dropped — which is exactly how
// applying a saved preset used to switch a mirroring host back to a virtual display
// (found on-glass, .136). Every orthogonal axis has to be listed.
capture_monitor: draft.capture_monitor ?? null,
// (found on-glass, .136). Every orthogonal axis has to be listed, and this one comes
// from the SERVER (see serverCaptureMonitor).
capture_monitor: serverCaptureMonitor(),
});
};
+43 -13
View File
@@ -83,6 +83,7 @@ export const SessionGameCard: FC = () => {
<Field
label={m.session_game_on_exit()}
help={m.session_game_on_exit_help()}
group
>
<div className="flex flex-wrap gap-2">
<Choice
@@ -105,6 +106,7 @@ export const SessionGameCard: FC = () => {
<Field
label={m.session_game_end_game()}
help={m.session_game_end_game_help()}
group
>
<div className="flex flex-wrap gap-2">
{END_POLICIES.map((p) => (
@@ -137,9 +139,11 @@ export const SessionGameCard: FC = () => {
<Field
label={m.session_game_grace()}
help={m.session_game_grace_help()}
htmlFor="session-grace-seconds"
>
<div className="flex items-center gap-2">
<Input
id="session-grace-seconds"
type="number"
min={10}
max={86400}
@@ -190,19 +194,45 @@ const END_POLICY_LABEL: Record<GameOnSessionEnd, () => string> = {
always: () => m.session_game_end_always(),
};
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>
);
/**
* A labelled block. `htmlFor` pairs the label with a single control; without one it is a group.
*
* A bare `<Label>` beside an `<input>` with no `id` labels nothing at all — the grace input was
* announced as an unnamed spin button. Mirrors the same fix in DisplayCard's `Field`; the two stay
* separate on purpose (this card's axes are its own).
*/
const Field: FC<{
label: string;
help?: string;
children: ReactNode;
htmlFor?: string;
group?: boolean;
}> = ({ label, help, children, htmlFor, group }) => {
const body = (
<>
<Label className="block" htmlFor={htmlFor}>
{label}
</Label>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</>
);
return group ? (
<fieldset className="space-y-3">
<legend className="mb-3 block text-sm font-medium leading-none">
{label}
</legend>
{children}
{help && (
<p className="max-w-prose text-xs text-muted-foreground">{help}</p>
)}
</fieldset>
) : (
<div className="space-y-3">{body}</div>
);
};
const Choice: FC<{
selected: boolean;