From f66de3eba4b96f76457dd3842dff94a2241234e5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 23:54:46 +0200 Subject: [PATCH] fix(web): close the last two high-severity items from the closeout audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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) --- web/src/sections/Displays/DisplayCard.tsx | 23 ++++++-- web/src/sections/Displays/SessionGameCard.tsx | 56 ++++++++++++++----- 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/web/src/sections/Displays/DisplayCard.tsx b/web/src/sections/Displays/DisplayCard.tsx index 53784e59..5692f0c0 100644 --- a/web/src/sections/Displays/DisplayCard.tsx +++ b/web/src/sections/Displays/DisplayCard.tsx @@ -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) => 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(), }); }; diff --git a/web/src/sections/Displays/SessionGameCard.tsx b/web/src/sections/Displays/SessionGameCard.tsx index 604b9a35..0ee9755a 100644 --- a/web/src/sections/Displays/SessionGameCard.tsx +++ b/web/src/sections/Displays/SessionGameCard.tsx @@ -83,6 +83,7 @@ export const SessionGameCard: FC = () => {
{
{END_POLICIES.map((p) => ( @@ -137,9 +139,11 @@ export const SessionGameCard: FC = () => {
string> = { always: () => m.session_game_end_always(), }; -const Field: FC<{ label: string; help?: string; children: ReactNode }> = ({ - label, - help, - children, -}) => ( -
- - {children} - {help && ( -

{help}

- )} -
-); +/** + * A labelled block. `htmlFor` pairs the label with a single control; without one it is a group. + * + * A bare `