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:
@@ -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(),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user