From 18771f1d49de28ef6e1a91439cdb1b6230e9561a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 9 Nov 2026 17:07:46 +0500 Subject: [PATCH 1/4] fix(browser): dim the report dialog and show the infinity loader while sending Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WeiJxSMPCqs6TBDKuGw9TG --- packages/browser/src/report-dialog-styles.ts | 35 ++++++++++++++++++++ packages/browser/src/report-dialog.test.ts | 26 +++++++++++++++ packages/browser/src/report-dialog.ts | 33 +++++++++++++++++- 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/report-dialog-styles.ts b/packages/browser/src/report-dialog-styles.ts index b053c957..88460391 100644 --- a/packages/browser/src/report-dialog-styles.ts +++ b/packages/browser/src/report-dialog-styles.ts @@ -278,6 +278,41 @@ textarea.${p}input { min-height: 8.2em; resize: vertical; } } .${p}confirm-discard { color: ${v('accent')}; } +/* While a send is in flight the form is dimmed and the viewer's infinity loader sits over it. */ +.${p}card[data-busy] .${p}bar, +.${p}card[data-busy] .${p}body, +.${p}card[data-busy] .${p}footer-row { opacity: 0.45; transition: opacity 120ms; } +.${p}busy { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: progress; +} +.${p}busy[hidden] { display: none; } +.${p}infinity { width: 80px; height: 40px; overflow: visible; display: block; } +.${p}infinity-track { + fill: none; + stroke: ${v('field-border')}; + stroke-width: 3; + stroke-linecap: round; + opacity: 0.5; +} +.${p}infinity-bar { + fill: none; + stroke: ${v('accent')}; + stroke-width: 3; + stroke-linecap: round; + stroke-dasharray: 25 75; + stroke-dashoffset: 0; + animation: ${p}infinity-travel 1.6s linear infinite; +} +@keyframes ${p}infinity-travel { to { stroke-dashoffset: -100; } } +@media (prefers-reduced-motion: reduce) { + .${p}infinity-bar { animation-duration: 4s; } +} + /* On a phone-sized viewport the card is the mobile SDKs' full-screen form. */ @media (max-width: 480px) { .${p}overlay { padding: 0; } diff --git a/packages/browser/src/report-dialog.test.ts b/packages/browser/src/report-dialog.test.ts index 026a6a31..1ea6f7e1 100644 --- a/packages/browser/src/report-dialog.test.ts +++ b/packages/browser/src/report-dialog.test.ts @@ -511,6 +511,32 @@ describe('sending', () => { expect(await pending).toEqual({ ok: false }); }); + it('dims the form and shows the infinity loader over it while sending', async () => { + let finish: (value: UploadResult) => void = () => {}; + const { show, upload } = harness(); + upload.mockImplementation(() => new Promise((resolve) => (finish = resolve))); + const pending = show(); + const busy = q('[data-busy-overlay]'); + const card = q('form'); + expect(busy.hasAttribute('hidden')).toBe(true); + expect(card.hasAttribute('data-busy')).toBe(false); + + send(); + + expect(busy.hasAttribute('hidden')).toBe(false); + expect(busy.getAttribute('role')).toBe('status'); + expect(busy.getAttribute('aria-label')).toBe('Sending…'); + expect(card.hasAttribute('data-busy')).toBe(true); + // the viewer's loader: a faint track and a travelling bar on the same lemniscate path + const paths = busy.querySelectorAll('svg path'); + expect(paths).toHaveLength(2); + expect(paths[0]?.getAttribute('d')).toBe(paths[1]?.getAttribute('d')); + expect(paths[1]?.getAttribute('class')).toContain('infinity-bar'); + + finish({ ok: true }); + await pending; + }); + it('closes and resolves a failed result rather than hanging when the upload throws', async () => { const { show, upload } = harness(); upload.mockRejectedValue(new Error('boom')); diff --git a/packages/browser/src/report-dialog.ts b/packages/browser/src/report-dialog.ts index a3b6129a..d9b9e576 100644 --- a/packages/browser/src/report-dialog.ts +++ b/packages/browser/src/report-dialog.ts @@ -103,6 +103,10 @@ export interface ReportDialogDeps { } const SVG_NS = 'http://www.w3.org/2000/svg'; + +// The viewer's loading mark: one closed lemniscate (infinity sign) in a 100×50 box. +const INFINITY_PATH = + 'M20,25 C20,5 35,5 50,25 C65,45 80,45 80,25 C80,5 65,5 50,25 C35,45 20,45 20,25 Z'; // Material "bug_report" and "close" glyphs (Apache-2.0). const BUG_PATH = 'M20 8h-2.81a5.985 5.985 0 0 0-1.82-1.96L17 4.41 15.59 3l-2.17 2.17a6.002 6.002 0 0 0-2.83 0L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z'; @@ -162,6 +166,7 @@ interface Refs { send: HTMLButtonElement; close: HTMLButtonElement; confirm: HTMLElement; + busyOverlay: HTMLElement; keep: HTMLButtonElement; discard: HTMLButtonElement; fields: Partial>; @@ -263,6 +268,9 @@ function showDialog( radio.disabled = value; } refs.close.disabled = value; + // Dim the form and cover it with the loader, so a send in flight is visible (not just a greyed button). + refs.form.toggleAttribute('data-busy', value); + refs.busyOverlay.toggleAttribute('hidden', !value); }; const showConfirm = (visible: boolean): void => { @@ -689,7 +697,29 @@ function build( hook(discardBody, 'confirm-body'); confirm.append(discardTitle, discardBody, actions); - form.append(bar, body, footerRow, confirm); + // The "sending" cover: the viewer's infinity-sign loader (a faint track with a bar travelling along the + // same lemniscate; see the `infinity-bar` animation in the styles), shown over the dimmed form. + const busyOverlay = make('div', { + role: 'status', + 'aria-label': strings.sending, + hidden: '', + }); + hook(busyOverlay, 'busy'); + busyOverlay.setAttribute('data-busy-overlay', ''); + const loader = doc.createElementNS(SVG_NS, 'svg'); + loader.setAttribute('viewBox', '0 0 100 50'); + loader.setAttribute('aria-hidden', 'true'); + hook(loader, 'infinity'); + for (const name of ['infinity-track', 'infinity-bar']) { + const path = doc.createElementNS(SVG_NS, 'path'); + path.setAttribute('d', INFINITY_PATH); + path.setAttribute('pathLength', '100'); + hook(path, name); + loader.append(path); + } + busyOverlay.append(loader); + + form.append(bar, body, footerRow, confirm, busyOverlay); dialog.append(form); shadow.append(dialog); @@ -700,6 +730,7 @@ function build( send, close, confirm, + busyOverlay, keep, discard, fields: fields as Refs['fields'], From 7214d6b3a66821229c46a53a2eca81d43198efaa Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 9 Nov 2026 18:24:44 +0500 Subject: [PATCH 2/4] fix(browser): announce the sending state, lock the footer link, no motion under reduced-motion - live region carries visually hidden text (screen readers read text, not aria-label); card gets aria-busy - powered-by link is inert while sending - prefers-reduced-motion: the loader bar is static - test asserts the dimming/reduced-motion rules Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WeiJxSMPCqs6TBDKuGw9TG --- packages/browser/src/report-dialog-styles.ts | 15 ++++++++++++++- packages/browser/src/report-dialog.test.ts | 18 ++++++++++++++++++ packages/browser/src/report-dialog.ts | 10 +++++++++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/browser/src/report-dialog-styles.ts b/packages/browser/src/report-dialog-styles.ts index 88460391..1629e292 100644 --- a/packages/browser/src/report-dialog-styles.ts +++ b/packages/browser/src/report-dialog-styles.ts @@ -309,8 +309,21 @@ textarea.${p}input { min-height: 8.2em; resize: vertical; } animation: ${p}infinity-travel 1.6s linear infinite; } @keyframes ${p}infinity-travel { to { stroke-dashoffset: -100; } } +/* Reduced motion means no motion (as with the card entry below): a static track with a part-drawn bar. */ @media (prefers-reduced-motion: reduce) { - .${p}infinity-bar { animation-duration: 4s; } + .${p}infinity-bar { animation: none; } +} +/* Visually hidden, still announced: the live region's text. */ +.${p}busy-text { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } /* On a phone-sized viewport the card is the mobile SDKs' full-screen form. */ diff --git a/packages/browser/src/report-dialog.test.ts b/packages/browser/src/report-dialog.test.ts index 1ea6f7e1..e85ac4b3 100644 --- a/packages/browser/src/report-dialog.test.ts +++ b/packages/browser/src/report-dialog.test.ts @@ -533,6 +533,24 @@ describe('sending', () => { expect(paths[0]?.getAttribute('d')).toBe(paths[1]?.getAttribute('d')); expect(paths[1]?.getAttribute('class')).toContain('infinity-bar'); + // announced by screen readers from TEXT content (not the accessible name), and the card says it is busy + expect(busy.textContent).toBe('Sending…'); + expect(card.getAttribute('aria-busy')).toBe('true'); + // the powered-by link is out of the tab order while the form is locked + expect(q('[data-powered]').closest('[inert]')).not.toBeNull(); + // the sections are dimmed by the stylesheet (jsdom does not cascade shadow-root styles, so read the rule) + const css = REPORT_DIALOG_CSS.replace(/\s+/g, ' '); + for (const section of ['bar', 'body', 'footer-row']) { + expect(css).toContain( + `.bugsee-reporting-dialog-card[data-busy] .bugsee-reporting-dialog-${section}`, + ); + } + expect(css).toMatch(/\[data-busy\] \.bugsee-reporting-dialog-footer-row \{ opacity: 0\.45/); + // reduced motion turns the travelling bar off + expect(css).toMatch( + /prefers-reduced-motion: reduce\) \{ \.bugsee-reporting-dialog-infinity-bar \{ animation: none; \}/, + ); + finish({ ok: true }); await pending; }); diff --git a/packages/browser/src/report-dialog.ts b/packages/browser/src/report-dialog.ts index d9b9e576..9f4e3c05 100644 --- a/packages/browser/src/report-dialog.ts +++ b/packages/browser/src/report-dialog.ts @@ -167,6 +167,7 @@ interface Refs { close: HTMLButtonElement; confirm: HTMLElement; busyOverlay: HTMLElement; + footer: HTMLElement; keep: HTMLButtonElement; discard: HTMLButtonElement; fields: Partial>; @@ -270,6 +271,9 @@ function showDialog( refs.close.disabled = value; // Dim the form and cover it with the loader, so a send in flight is visible (not just a greyed button). refs.form.toggleAttribute('data-busy', value); + refs.form.setAttribute('aria-busy', String(value)); + // The powered-by link is the one control left enabled: keep it out of the tab order while sending. + refs.footer.toggleAttribute('inert', value); refs.busyOverlay.toggleAttribute('hidden', !value); }; @@ -717,7 +721,10 @@ function build( hook(path, name); loader.append(path); } - busyOverlay.append(loader); + // The announced part of the live region: screen readers read a status region's TEXT, not its label. + const busyText = make('span', {}, strings.sending); + hook(busyText, 'busy-text'); + busyOverlay.append(loader, busyText); form.append(bar, body, footerRow, confirm, busyOverlay); dialog.append(form); @@ -731,6 +738,7 @@ function build( close, confirm, busyOverlay, + footer: footerRow, keep, discard, fields: fields as Refs['fields'], From 23226f71b52202dbfef129cb5d9a663d546a6228 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 10 Nov 2026 22:49:14 +0500 Subject: [PATCH 3/4] fix(browser): announce sending from a persistent live region outside the form aria-busy on an ancestor suppresses live-region announcements and an unhidden region is unreliable; use an always-rendered empty status region beside the form whose text is set when a send starts. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WeiJxSMPCqs6TBDKuGw9TG --- packages/browser/src/report-dialog-styles.ts | 4 ++-- packages/browser/src/report-dialog.test.ts | 14 ++++++----- packages/browser/src/report-dialog.ts | 25 +++++++++++--------- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/browser/src/report-dialog-styles.ts b/packages/browser/src/report-dialog-styles.ts index 1629e292..40eed3b5 100644 --- a/packages/browser/src/report-dialog-styles.ts +++ b/packages/browser/src/report-dialog-styles.ts @@ -313,8 +313,8 @@ textarea.${p}input { min-height: 8.2em; resize: vertical; } @media (prefers-reduced-motion: reduce) { .${p}infinity-bar { animation: none; } } -/* Visually hidden, still announced: the live region's text. */ -.${p}busy-text { +/* Visually hidden, still announced: the live region that speaks "Sending…". */ +.${p}announcer { position: absolute; width: 1px; height: 1px; diff --git a/packages/browser/src/report-dialog.test.ts b/packages/browser/src/report-dialog.test.ts index e85ac4b3..40d0d5cf 100644 --- a/packages/browser/src/report-dialog.test.ts +++ b/packages/browser/src/report-dialog.test.ts @@ -517,6 +517,8 @@ describe('sending', () => { upload.mockImplementation(() => new Promise((resolve) => (finish = resolve))); const pending = show(); const busy = q('[data-busy-overlay]'); + const announcer = q('[data-announcer]'); + expect(announcer.textContent).toBe(''); const card = q('form'); expect(busy.hasAttribute('hidden')).toBe(true); expect(card.hasAttribute('data-busy')).toBe(false); @@ -524,18 +526,18 @@ describe('sending', () => { send(); expect(busy.hasAttribute('hidden')).toBe(false); - expect(busy.getAttribute('role')).toBe('status'); - expect(busy.getAttribute('aria-label')).toBe('Sending…'); - expect(card.hasAttribute('data-busy')).toBe(true); + expect(busy.getAttribute('aria-hidden')).toBe('true'); // visual only; the spoken state is the announcer // the viewer's loader: a faint track and a travelling bar on the same lemniscate path const paths = busy.querySelectorAll('svg path'); expect(paths).toHaveLength(2); expect(paths[0]?.getAttribute('d')).toBe(paths[1]?.getAttribute('d')); expect(paths[1]?.getAttribute('class')).toContain('infinity-bar'); - // announced by screen readers from TEXT content (not the accessible name), and the card says it is busy - expect(busy.textContent).toBe('Sending…'); - expect(card.getAttribute('aria-busy')).toBe('true'); + // spoken from an always-rendered live region OUTSIDE the form (no busy ancestor), its text set on send + expect(announcer.textContent).toBe('Sending…'); + expect(announcer.getAttribute('role')).toBe('status'); + expect(card.contains(announcer)).toBe(false); + expect(card.hasAttribute('aria-busy')).toBe(false); // the powered-by link is out of the tab order while the form is locked expect(q('[data-powered]').closest('[inert]')).not.toBeNull(); // the sections are dimmed by the stylesheet (jsdom does not cascade shadow-root styles, so read the rule) diff --git a/packages/browser/src/report-dialog.ts b/packages/browser/src/report-dialog.ts index 9f4e3c05..35001810 100644 --- a/packages/browser/src/report-dialog.ts +++ b/packages/browser/src/report-dialog.ts @@ -167,6 +167,7 @@ interface Refs { close: HTMLButtonElement; confirm: HTMLElement; busyOverlay: HTMLElement; + announcer: HTMLElement; footer: HTMLElement; keep: HTMLButtonElement; discard: HTMLButtonElement; @@ -271,7 +272,7 @@ function showDialog( refs.close.disabled = value; // Dim the form and cover it with the loader, so a send in flight is visible (not just a greyed button). refs.form.toggleAttribute('data-busy', value); - refs.form.setAttribute('aria-busy', String(value)); + refs.announcer.textContent = value ? strings.sending : ''; // The powered-by link is the one control left enabled: keep it out of the tab order while sending. refs.footer.toggleAttribute('inert', value); refs.busyOverlay.toggleAttribute('hidden', !value); @@ -703,11 +704,8 @@ function build( // The "sending" cover: the viewer's infinity-sign loader (a faint track with a bar travelling along the // same lemniscate; see the `infinity-bar` animation in the styles), shown over the dimmed form. - const busyOverlay = make('div', { - role: 'status', - 'aria-label': strings.sending, - hidden: '', - }); + // Purely visual (`aria-hidden`): the spoken state is the persistent live region below, outside the form. + const busyOverlay = make('div', { 'aria-hidden': 'true', hidden: '' }); hook(busyOverlay, 'busy'); busyOverlay.setAttribute('data-busy-overlay', ''); const loader = doc.createElementNS(SVG_NS, 'svg'); @@ -721,13 +719,17 @@ function build( hook(path, name); loader.append(path); } - // The announced part of the live region: screen readers read a status region's TEXT, not its label. - const busyText = make('span', {}, strings.sending); - hook(busyText, 'busy-text'); - busyOverlay.append(loader, busyText); + busyOverlay.append(loader); + + // The announcement: an always-rendered, initially EMPTY live region OUTSIDE the form (so nothing busy is + // its ancestor), whose text is set when a send starts — screen readers announce a text change in an + // exposed region far more reliably than a region that is merely unhidden. Visually hidden. + const announcer = make('div', { role: 'status', 'aria-live': 'polite' }); + hook(announcer, 'announcer'); + announcer.setAttribute('data-announcer', ''); form.append(bar, body, footerRow, confirm, busyOverlay); - dialog.append(form); + dialog.append(form, announcer); shadow.append(dialog); return { @@ -738,6 +740,7 @@ function build( close, confirm, busyOverlay, + announcer, footer: footerRow, keep, discard, From 960dc65645565499d3a7eec2b273ccb3b5dc51cf Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 10 Nov 2026 22:58:22 +0500 Subject: [PATCH 4/4] test(browser): the sending announcement uses the localized string Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WeiJxSMPCqs6TBDKuGw9TG --- packages/browser/src/report-dialog.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/browser/src/report-dialog.test.ts b/packages/browser/src/report-dialog.test.ts index 40d0d5cf..fd9956ec 100644 --- a/packages/browser/src/report-dialog.test.ts +++ b/packages/browser/src/report-dialog.test.ts @@ -557,6 +557,17 @@ describe('sending', () => { await pending; }); + it('speaks the localized "sending" string', async () => { + const { show, upload } = harness(); + upload.mockImplementation(() => new Promise(() => {})); + void show({ strings: { sending: 'Wird gesendet…' } }); + + send(); + + expect(q('[data-announcer]').textContent).toBe('Wird gesendet…'); + expect(q('[data-action="send"]').getAttribute('aria-label')).toBe('Wird gesendet…'); + }); + it('closes and resolves a failed result rather than hanging when the upload throws', async () => { const { show, upload } = harness(); upload.mockRejectedValue(new Error('boom'));