Skip to content

feat: graceful avatar fallback with deterministic initials in TournamentParticipants#624

Open
pitah23 wants to merge 3 commits into
Arenax-gaming:mainfrom
pitah23:feat/tournament-avatar-fallback
Open

feat: graceful avatar fallback with deterministic initials in TournamentParticipants#624
pitah23 wants to merge 3 commits into
Arenax-gaming:mainfrom
pitah23:feat/tournament-avatar-fallback

Conversation

@pitah23

@pitah23 pitah23 commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Closes #548


Summary

TournamentParticipants previously rendered every avatar as a hardcoded gradient div with the player's first letter — ignoring the avatar URL field entirely. This PR replaces that with a ParticipantAvatar component that:

  • Loads the real avatar image when a URL is present
  • Silently falls back to an initials placeholder when the URL is missing or the image fails to load
  • Assigns each user a consistent placeholder color derived deterministically from their username — so the same player always gets the same color, across rerenders, remounts, and sessions
  • Exposes proper accessibility attributes on both the image and the placeholder

Changes

TournamentParticipants.tsx

What Detail
"use client" Added — component now uses useState
AVATAR_COLORS (exported) 16-slot Tailwind color palette; all class names appear as string literals so JIT compilation includes them
hashUsername (exported) djb2-style hash over char codes, Math.imul for 32-bit integer arithmetic, >>> 0 for unsigned result — deterministic, no floating-point drift
getAvatarColor (exported) Maps any username to a stable AVATAR_COLORS slot via hashUsername(username) % 16
ParticipantAvatar (exported) New component — renders <img src={avatarUrl} alt={username}> when URL is non-empty; on onError sets imgFailed = true, unmounting the img and switching to the initials div; placeholder carries role="img" aria-label={username}
Integration Participant rows now use <ParticipantAvatar> instead of the old static div

__tests__/tournament-participants.test.tsx — 36 tests

See test plan below.


How the fallback works

avatarUrl provided?
  └─ yes → render <img onError={() => setImgFailed(true)} alt={username}>
            │
            └─ load succeeds → image shown
            └─ load fails   → imgFailed=true → img unmounted
                              → render initials placeholder
                              (no further error events possible)
  └─ no  → render initials placeholder immediately

Once imgFailed is true the <img> element is removed from the DOM, so the browser's error event cannot fire again — preventing any re-render loop.


Accessibility

State Element Accessible name
Image loaded <img alt="username"> username
No URL / failure <div role="img" aria-label="username"> username

Both states satisfy WCAG 1.1.1 (Non-text Content) with an equivalent text alternative.


Deterministic color assignment

// djb2-style hash — same input always produces the same unsigned 32-bit integer
function hashUsername(username: string): number {
  let h = 5381;
  for (let i = 0; i < username.length; i++) {
    h = (Math.imul(h, 31) + username.charCodeAt(i)) >>> 0;
  }
  return h;
}

// Maps to one of 16 Tailwind colors
getAvatarColor(username) === AVATAR_COLORS[hashUsername(username) % 16]

A given username will receive the same color forever, without any runtime storage or server coordination.


Tests

hashUsername (5)

  • Returns the same value for the same input
  • Returns different values for different inputs
  • Returns a non-negative integer
  • Handles empty string without throwing
  • Handles unicode characters without throwing

getAvatarColor (4)

  • Always returns the same color for the same username
  • Returned color is always in AVATAR_COLORS
  • Different usernames distribute across multiple colors
  • Stable across 10 repeated calls (no randomness)

ParticipantAvatar — valid image (4)

  • Renders <img> when avatarUrl is provided
  • src matches the provided URL
  • alt attribute equals the username
  • No initials text rendered alongside the image

ParticipantAvatar — missing avatar (4)

  • null URL → initials placeholder with role="img"
  • undefined URL → initials placeholder
  • Empty string URL → initials placeholder
  • No <img> element in the DOM when URL is null

ParticipantAvatar — initials (3)

  • Shows uppercase first letter of the username
  • Uppercases even when username starts lowercase
  • Shows the first letter, not any other character

ParticipantAvatar — accessibility (3)

  • Placeholder has role="img" and aria-label matching username
  • <img> has alt equal to username, tag is IMG
  • getByRole('img', { name: username }) resolves for placeholder

ParticipantAvatar — image load failure (4)

  • fireEvent.error → initials placeholder appears
  • <img> is removed from DOM after failure (no repeated error)
  • Correct initial rendered after failure
  • role="img" aria-label preserved after failure

ParticipantAvatar — deterministic color (3)

  • A palette color class is present on the placeholder element
  • Same class rendered across multiple renders of the same username
  • Class matches getAvatarColor output

TournamentParticipants integration (6)

  • Renders without crashing
  • Shows "Participants" heading
  • Renders at least one avatar element per participant
  • Shows correct slot count text
  • Shows full tournament state
  • Shows available slots count

Test plan

  • Visit a tournament detail page — avatar images load for players with valid avatar URLs
  • Open DevTools Network → block api.dicebear.com — avatars switch to coloured initials on failure, no console errors about infinite re-renders
  • Compare two renders of the same participant — the placeholder color is identical both times
  • Check two different usernames — they may receive different placeholder colors
  • Inspect avatar <img> tags — each has a non-empty alt attribute equal to the player's username
  • Run a screen reader over the participant list — each avatar is announced by the player's username regardless of load state
  • Verify no broken-image icons appear in any avatar slot under any network condition

pitah23 added 3 commits June 25, 2026 19:54
…to ProfileBio

- Define MAX_BIO_LENGTH (280) in src/lib/validations/profile.ts as the single
  source of truth, shared by the Zod schema and all UI components
- Update ProfileBio.tsx with a live character counter (currentLength / MAX),
  warning style (text-destructive) at ≥90% capacity, aria-live announcement,
  and inline Zod-driven error that blocks Save when the limit is exceeded
- Update profile/edit/page.tsx to import MAX_BIO_LENGTH from the shared
  location, removing the local 500-char constant that mismatched the server
- Add accessible label/id to the bio textarea in the edit page
- Fix broken import path in profile-edit.test.tsx and add useSearchParams mock
- Add profile-bio.test.tsx with 16 tests covering counter updates, 90%
  warning threshold, submission blocking, successful save, and constant/schema
  consistency
- Add storageKey="theme" to ThemeProvider so next-themes writes to and
  reads from the "theme" localStorage key (defaultTheme="system",
  enableSystem, and suppressHydrationWarning were already in place)
- Fix both ThemeToggle components to use resolvedTheme for the
  toggle decision, which correctly handles "system" mode (previously
  used the raw theme value, so system+dark-OS would not toggle to light)
- Add mounted guard to both ThemeToggle components: SSR renders a stable
  placeholder button (no Sun/Moon icons, no onClick) so users never see
  a flash of the wrong icon before client hydration
- Wire ThemeSelector to useTheme so its active-mode highlight reflects
  the persisted next-themes state instead of the stale settings prop;
  mode selection now calls setTheme (persistence) and onUpdate (app state)
  together; add same mounted guard for hydration safety

Tests (27) in theme-persistence.test.tsx:
- ThemeProvider forwards storageKey, defaultTheme, enableSystem correctly
- localStorage: selected theme stored under "theme", restored on remount,
  falls back to system when no preference is present
- ThemeToggle (both): light↔dark toggle, system+OS-pref toggle,
  SSR output verified via renderToStaticMarkup (placeholder, no onclick)
- ThemeSelector: active mode from useTheme not settings, setTheme+onUpdate
  called together, correct highlight for all three modes
- Cross-component sync: ThemeToggle and ThemeSelector reflect same state
…entParticipants

Replace the static gradient placeholder in TournamentParticipants with a
ParticipantAvatar component that tries to load the real avatar image first
and falls back to an initials-based placeholder only when needed.

What changed:
- Extract ParticipantAvatar component that renders <img> when avatarUrl is
  non-empty, and a styled div with the first letter of the username when
  avatarUrl is null/empty or the image fails to load
- Add hashUsername (djb2/unsigned-32-bit) + getAvatarColor to map each
  username to a stable slot in a 16-color Tailwind palette; all color class
  names appear as string literals so Tailwind JIT includes them
- Image load failures set imgFailed state once via onError; the img is then
  unmounted so the error callback cannot fire again, preventing repeated
  re-renders from repeated failures
- Every <img> carries alt={username}; the initials placeholder carries
  role="img" aria-label={username} so screen readers get an equivalent label
  in both states
- Add "use client" directive since the component now uses useState

Tests (36) in tournament-participants.test.tsx:
- hashUsername: determinism, distinct values, non-negative int, edge cases
- getAvatarColor: stable per username, always in palette, distributed across names
- ParticipantAvatar valid image: img rendered, src and alt correct, no initials shown
- ParticipantAvatar missing avatar: null/undefined/empty → placeholder + initials
- ParticipantAvatar initials: uppercase first letter, correct character
- ParticipantAvatar accessibility: aria-label on placeholder, alt on img
- ParticipantAvatar image failure: onError → placeholder shown, img removed,
  correct initial, aria-label preserved
- ParticipantAvatar color: palette class applied, same class across renders,
  matches getAvatarColor output
- TournamentParticipants integration: renders without crash, heading, one
  avatar per participant, slot counts, full/available states
@pitah23 pitah23 requested a review from anonfedora as a code owner June 25, 2026 21:54
@vercel

vercel Bot commented Jun 25, 2026

Copy link
Copy Markdown

@pitah23 is attempting to deploy a commit to the paul joseph's projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Jun 25, 2026

Copy link
Copy Markdown

@pitah23 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

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.

[frontend] - Tournament participants list shows no avatar fallback

1 participant