Skip to content

fix(widgets): four accessibility defects an audit measured - #101

Merged
florianmousseau merged 1 commit into
mainfrom
fix/widgets-accessibility
Jul 31, 2026
Merged

fix(widgets): four accessibility defects an audit measured#101
florianmousseau merged 1 commit into
mainfrom
fix/widgets-accessibility

Conversation

@florianmousseau

Copy link
Copy Markdown
Contributor

An optimization audit on weight, green IT and accessibility. Weight and energy
came back clean; accessibility did not. Four defects, each measured, each
inherited by every app that uses the widgets.

1. The default severity colors were unreadable, as text

Token Was Contrast on white
--selfstore-ok #16a34a 3.30:1 fails AA
--selfstore-warn #d97706 3.19:1 fails AA

Not decoration: sev-ok, sev-warn and warn-note set them as color, so
the status sentence itself failed the 4.5:1 text threshold. Both move one step
darker (#15803d, #b45309), landing at 5.02:1, without changing the color
anyone thinks they see.

A consequence worth stating: chosen for the same ratio, they now share a
luminance, so on a greyscale screen they are the same grey. That is acceptable
only because severity also carries a glyph, and a test asserts the glyph so
removing it fails here rather than in someone's hands.

2. The blocking gate did not hold the keyboard

role="dialog" and aria-modal="true" tell assistive tech the screen is
modal. Neither touches the Tab key. A keyboard user tabbed out of the first-run
gate into the application behind it, still covered by the screen they were no
longer in, driving something they could not see.

Focus that lands outside is pulled back, and closing the gate returns focus
where it was. A guard rather than a cycle through focusables: the gate holds a
<selfstore-connect> with its own shadow root, and walking focusables across
shadow boundaries is the kind of code that quietly stops matching the DOM.

3. Nothing was ever announced

Not one aria-live in any widget. A screen reader user was never told a save
started, that access expired, or that a destination needs reconnecting - from
the element whose entire job is to say the situation changed.

<selfstore-status> is a polite live region across its three variants, set on
the host so an app can still override it. Every error note carries
role="alert".

4. The spinner ignored prefers-reduced-motion

The one animation in the library, an endless rotation, during exactly the wait
nobody can leave.

What the audit cleared

Worth recording so it is not re-audited: the core entry is 17.4 kB brotli,
and both heavy dependencies are already lazy - hash-wasm (61 kB) and fflate
(10.8 kB) load only when a key is derived or an archive is read. The 5-minute
sync interval is guarded by tab visibility, syncIfStale throttles at 20 s and
does a cheap stat() before downloading, and everything tears down. There is
nothing to reclaim there.

One weight finding remains and is not in this PR: the simple store statically
imports Drive, WebDAV and S3, so an app using only file ships 14 kB it never
runs. Same shape as the widget barrel fixed in #100.

Verification

Gate green, exit 0. 766 tests over 64 files, 8 new.

Two things the tests caught, both real: pulling focus back fires another
focusin, so the guard recursed until the stack gave out until a re-entrancy
flag was added - it failed in this suite before it could fail in a browser. And
asserting "the listener is gone" had to be done on the listener, because a
detached host stays document.activeElement in this DOM and the observable
outcome could not tell the two states apart.

Coverage moved less than a rounding step, so the ratchet stays.

Contrast: --selfstore-ok sat at 3.30:1 on white and --selfstore-warn at
3.19:1, where AA asks 4.5:1 for text. They are used as color on sev-ok,
sev-warn and warn-note, so the status sentence itself failed. One step
darker each, 5.02:1, same perceived hue. A test computes the ratio now.

Focus: the gate declared role=dialog and aria-modal=true, which tells
assistive tech it is modal and does nothing to Tab. A keyboard user left
the blocking screen for the application behind it, still covered by the
gate they were no longer in. Focus landing outside comes back, and closing
returns it where it was. A guard rather than a focusable cycle, because
the gate holds a child with its own shadow root.

Announcements: no widget had a live region. selfstore-status is now a
polite one across its three variants, and every error note is role=alert.

Motion: the spinner respects prefers-reduced-motion.

Found by the tests, and worth naming: pulling focus back fires another
focusin, so the guard needed a re-entrancy flag or it recursed until the
stack gave out. GLYPH also left the render function for module scope - it
is the channel that carries severity when the two colors, darkened to the
same threshold, share a luminance.
@sonarqubecloud

Copy link
Copy Markdown

@florianmousseau
florianmousseau merged commit 4b9abf0 into main Jul 31, 2026
7 checks passed
@florianmousseau
florianmousseau deleted the fix/widgets-accessibility branch July 31, 2026 21:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant