From d7cf180516a89a5424317ed2fadd3aff9cff0611 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 13 Aug 2026 16:28:43 -0400 Subject: [PATCH] feat(popup): say the status region is busy while the page is still unchecked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Checking this page…" is the worker not having answered about the tab yet, which is the same kind of not-finished the publication card already reports with aria-busy. The message carries a `pending` flag and renderStatus marks the status region with it, so "still working" is one question anything outside the popup can ask. Nothing visible changes. It gives assistive technology the region's own state instead of a line it has to re-read, and it gives the capture harness a detection state it can wait for. --- src/lib/status.test.ts | 21 +++++++++++++++++++-- src/lib/status.ts | 10 +++++++++- src/popup/cards/loading.test.ts | 30 ++++++++++++++++++++++++++++++ src/popup/popup.ts | 5 +++++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/lib/status.test.ts b/src/lib/status.test.ts index 87b9b0b..ae590c0 100644 --- a/src/lib/status.test.ts +++ b/src/lib/status.test.ts @@ -32,8 +32,25 @@ describe('statusMessagesFor', () => { expect(msgs[0]?.text).toMatch(/Refresh/) }) - it('shows checking while no state is known yet', () => { - expect(statusMessagesFor({ ...base, state: undefined })[0]?.text).toMatch(/Checking/) + it('shows checking while no state is known yet, and marks it as not an answer', () => { + const checking = statusMessagesFor({ ...base, state: undefined })[0] + expect(checking?.text).toMatch(/Checking/) + // The popup turns this into aria-busy on the status region; a screenshot + // taken while it is up is a screenshot of detection that has not finished + // (scripts/capture-status-docs.mjs). + expect(checking?.pending).toBe(true) + }) + + it('marks nothing else pending: every other line is an answer', () => { + const answers = [ + statusMessagesFor({ ...base, state: undefined, requestFailed: true }), + statusMessagesFor({ ...base, state: undefined, httpPage: false }), + statusMessagesFor({ ...base, state: state({ error: 'offline' }) }), + statusMessagesFor({ ...base, state: state({}), sessionExpired: true }), + ] + for (const msgs of answers) { + expect(msgs.some((msg) => msg.pending)).toBe(false) + } }) it('distinguishes offline from a record fetch failure', () => { diff --git a/src/lib/status.ts b/src/lib/status.ts index 3ceb9ef..f3f0a0e 100644 --- a/src/lib/status.ts +++ b/src/lib/status.ts @@ -22,6 +22,14 @@ export interface StatusMessage { tone: StatusTone /** A substring of `text` to render as a link pointing at `href`. */ link?: { text: string; href: string } + /** + * This line is not an answer, it is the absence of one: the worker has not + * said anything about this page yet. The popup marks the status region + * `aria-busy` for it, the same way the publication card does while its own + * lookups are out — so "still working" is one question anything outside the + * popup can ask, including the capture (scripts/capture-status-docs.mjs). + */ + pending?: boolean } export interface StatusInput { @@ -80,7 +88,7 @@ export function statusMessagesFor(input: StatusInput): StatusMessage[] { tone: 'error', }) } else if (!state) { - messages.push({ text: 'Checking this page…', tone: 'info' }) + messages.push({ text: 'Checking this page…', tone: 'info', pending: true }) } else { if (state.error === 'offline') { messages.push({ diff --git a/src/popup/cards/loading.test.ts b/src/popup/cards/loading.test.ts index d123e3b..e4c8e4a 100644 --- a/src/popup/cards/loading.test.ts +++ b/src/popup/cards/loading.test.ts @@ -120,4 +120,34 @@ describe('LoadTracker', () => { expect(tracker.busy('labels')).toBe(false) expect(tracker.anyPending()).toBe(false) }) + + // What `aria-busy` is worth to anything reading it from outside — assistive + // technology, and the capture (scripts/capture-status-docs.mjs), which + // refuses to shoot a card that has it. The card sets it from anyPending, so + // a placeholder on screen with the attribute already gone would be a + // screenshot of a half-drawn popup that the capture had been told was done. + it('is pending for as long as anything is drawing a placeholder', async () => { + const tracker = new LoadTracker(vi.fn()) + const fast = deferred() + const slow = deferred() + tracker.track('owner', fast.work) + tracker.track('subscribers', slow.work) + + for (const at of [0, SHOW_AFTER_MS - 1, 1, 500]) { + await tick(at) + const showing = ['owner', 'subscribers'].filter((id) => tracker.busy(id)) + if (showing.length > 0) expect(tracker.anyPending()).toBe(true) + } + + fast.settle() + await tick(0) + // The slow one is still drawing, so the card is still busy. + expect(tracker.busy('subscribers')).toBe(true) + expect(tracker.anyPending()).toBe(true) + + slow.settle() + await tick(0) + expect(tracker.busy('subscribers')).toBe(false) + expect(tracker.anyPending()).toBe(false) + }) }) diff --git a/src/popup/popup.ts b/src/popup/popup.ts index 1b82558..23759b9 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -288,6 +288,11 @@ function renderStatus() { const el = $('status') el.hidden = messages.length === 0 el.replaceChildren(...messages.map(pillFor)) + // "Checking this page…" is the worker not having answered yet, which is the + // same kind of not-finished the publication card reports while its lookups + // are out — said here in the same way, on the region that is showing it. + if (messages.some((msg) => msg.pending)) el.setAttribute('aria-busy', 'true') + else el.removeAttribute('aria-busy') } function pillFor(msg: StatusMessage): HTMLElement { -- 2.51.2