Skip to content

Fix theme-toggle icon flash, add avatar-stack/empty-state a11y labels, add Button loading prop - #322

Merged
chonilius merged 4 commits into
MergeFi:mainfrom
richardtoms100:fix/theme-flash-avatar-label-emptystate-aria-hidden-button-loading
Aug 25, 2026
Merged

Fix theme-toggle icon flash, add avatar-stack/empty-state a11y labels, add Button loading prop#322
chonilius merged 4 commits into
MergeFi:mainfrom
richardtoms100:fix/theme-flash-avatar-label-emptystate-aria-hidden-button-loading

Conversation

@richardtoms100

Copy link
Copy Markdown
Contributor

Summary

Four fixes spanning a visual flash bug and three accessibility gaps:

Theme icon flash on load (#208)ThemeProvider initialized theme state unconditionally to "light", correcting it a render later inside a useEffect by reading back the class the inline themeInitScript had already applied to <html> before hydration. Between first paint and that effect running, ThemeToggle rendered its icon from the wrong state — a visitor whose page was actually dark saw a Moon icon (meaning "click to go dark") for one render, a visible flash on every page load in dark mode. Switched to a lazy useState initializer that reads the DOM class synchronously at mount/hydration time instead.

Unlabeled avatar overflow badge (#209)AvatarStack's "+N" overflow indicator had no aria-label/title/text alternative — a screen reader read only "+3" with no indication it represents hidden contributors. Added aria-label={+${rest} more contributors} plus a title listing the hidden seeds.

Non-decorative-marked EmptyState icon (#210) — the icon had no aria-hidden, despite every meaningful message already being present as text below it. EmptyState is used across every dashboard/list view, so the gap repeated everywhere. Added aria-hidden="true", matching StatCard.tsx's existing pattern.

No built-in loading/aria-busy support on Button (#211) — every async-action call site (fund, claim, refund, deposit, wallet connect — 5 places total) reimplemented disabled={pending || connecting} + a manually swapped text label independently, with no aria-busy anywhere. Added a loading prop to Button that sets aria-busy="true", forces disabled, and renders a small spinner — then migrated all five existing call sites to use it instead of leaving the new prop unused alongside the old pattern.

Changes

  • src/context/ThemeContext.tsx — lazy initial-state read.
  • src/components/ui/Avatar.tsxaria-label/title on the overflow badge.
  • src/components/ui/EmptyState.tsxaria-hidden on the icon.
  • src/components/ui/Button.tsx / new Button.test.tsxloading prop + tests.
  • src/app/issues/[id]/IssueActions.tsx, src/app/milestones/MilestoneActions.tsx, src/app/connect/ConnectPanel.tsx — migrated to loading.

Test plan

  • npx tsc --noEmit — clean
  • npx eslint on all changed files — clean
  • npx jest (full suite) — 207/207 passing, no regressions

Closes #208
Closes #209
Closes #210
Closes #211

ThemeProvider initialized theme state unconditionally to "light", then
corrected it a render later inside a useEffect by reading back the
class themeInitScript had already applied to <html> before hydration.
Between first paint and that effect running, theme was "light"
regardless of the visitor's actual preference — ThemeToggle renders its
icon directly from this state, so a visitor whose page was actually
dark saw a Moon icon (meaning "click to go dark") for one render even
though the page was already dark: the icon momentarily backwards
relative to the real state, a visible flash on every page load in dark
mode.

Switched to a lazy useState initializer that reads
document.documentElement.classList synchronously at mount/hydration
time instead of hardcoding "light" and fixing it up afterward — safe
here since ThemeProvider is a Client Component executing after the
inline theme-init script has already run. Guarded for SSR, where this
component still executes once with no `document` available; the
client's own hydration render (which is what actually paints) always
has it by then (closes MergeFi#208).
AvatarStack's numeric overflow indicator had no aria-label, title, or
any text alternative beyond the bare "+{rest}" string. A screen reader
encountering it read only "+3" with no indication it represents
additional, hidden contributors beyond the ones already announced —
sighted users infer this from the stacked-circle visual context, which
isn't conveyed to assistive technology. This badge sits right next to
the homepage's "Joined by N contributors already earning" copy, so it's
one of the first interactive-looking elements a screen reader user
encounters on the page.

Added aria-label={`+${rest} more contributors`} plus a title listing
the hidden seeds, for sighted mouse users hovering it (closes MergeFi#209).
EmptyState's icon had no aria-hidden and no accessible-name
suppression, despite every meaningful message it conveys already being
present as text immediately below it — the icon is purely decorative,
but some screen readers may still attempt to announce it (lucide icons
render as inline SVG, and an unlabeled SVG's a11y treatment varies by
browser/AT). EmptyState is used across every dashboard and list view in
the app, so this same gap repeated everywhere it's rendered.

Added aria-hidden="true", the same way StatCard.tsx already does for
its own decorative icons (closes MergeFi#210).
…the pending pattern

Button exposed no loading/pending prop — every consumer performing an
async action reimplemented the same manual pattern independently
(disabled={pending || connecting} + a manually swapped text label),
across five separate call sites in IssueActions, MilestoneActions
(x2), and ConnectPanel. None set aria-busy while pending, and none
rendered a visual spinner — only the text label changed, which isn't
reliably announced to a screen reader without an aria-live region, and
a low-vision user relying on zoom/high contrast may not notice a
text-only change either. Every one of these is a real, often
money-moving action (fund, claim, refund, deposit, wallet connect).

Added a `loading` prop to Button: sets aria-busy="true", forces
disabled (independent of an explicitly-passed disabled), and renders a
small inline spinner. Migrated all five existing call sites from
disabled={pending || connecting} to loading={pending || connecting},
so the standardized behavior actually replaces the five independent
reimplementations rather than existing unused alongside them. Added
Button.test.tsx (no prior test file existed) covering the default,
loading, and explicitly-disabled states (closes MergeFi#211).
@vercel

vercel Bot commented Aug 25, 2026

Copy link
Copy Markdown

@richardtoms100 is attempting to deploy a commit to the chonilius' projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Aug 25, 2026

Copy link
Copy Markdown

@richardtoms100 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@chonilius
chonilius merged commit 7b15731 into MergeFi:main Aug 25, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment