Skip to content

Commit 34561d8

Browse files
akabiruclaude
andcommitted
Reduce avatar fallback flicker by rendering initials server-side
Moves initial extraction from client-side JavaScript to server-side Ruby, significantly reducing visual flicker on page load. JavaScript now only applies the background color hash, rather than both initials and color. Also sets a neutral dark gray default fill (hsl(0, 0%, 35%)) instead of currentColor to minimize color transition flash in both light and dark modes. Changes: - Extract initials in Ruby's `extract_initials` method - Render initials directly in SVG text element server-side - Remove `extractInitials` method from TypeScript - Use neutral gray fill matching final hashed color lightness (30-35%) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent d883408 commit 34561d8

2 files changed

Lines changed: 26 additions & 37 deletions

File tree

app/components/primer/open_project/avatar_fallback.ts

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,47 +11,16 @@ export class AvatarFallbackElement extends HTMLElement {
1111
const fallbackSvg = this.querySelector('svg[role="img"]')
1212
if (!fallbackSvg) return
1313

14-
// Extract initials from alt text
15-
const initials = this.extractInitials(this.altText)
16-
1714
// Generate consistent color based on uniqueId and altText (hash must match OP Core)
1815
const text = `${this.uniqueId}${this.altText}`
1916
const hue = this.valueHash(text)
2017
const color = `hsl(${hue}, 50%, 30%)`
2118

22-
// Set background color on rect element and initials on text element
19+
// Set background color on rect element
2320
const rectElement = fallbackSvg.querySelector('rect')
24-
const textElement = fallbackSvg.querySelector('text')
25-
2621
if (rectElement) {
2722
rectElement.setAttribute('fill', color)
2823
}
29-
30-
if (textElement) {
31-
textElement.textContent = initials
32-
}
33-
}
34-
35-
/*
36-
* Extracts initials from a name string (first letter + last letter of last word)
37-
* @param name - The name to extract initials from
38-
* @returns The initials (1-2 characters)
39-
*/
40-
private extractInitials(name: string): string {
41-
if (!name) return ''
42-
43-
const trimmed = name.trim()
44-
if (!trimmed) return ''
45-
46-
const first = trimmed.charAt(0).toUpperCase()
47-
48-
const lastSpace = trimmed.lastIndexOf(' ')
49-
if (lastSpace > 0 && lastSpace < trimmed.length - 1) {
50-
const last = trimmed.charAt(lastSpace + 1).toUpperCase()
51-
return `${first}${last}`
52-
}
53-
54-
return first
5524
}
5625

5726
/*

app/components/primer/open_project/avatar_with_fallback.rb

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ module OpenProject
55
# OpenProject-specific Avatar component that extends Primer::Beta::Avatar
66
# to support fallback rendering with initials when no image source is provided.
77
#
8-
# When `src` is nil, this component renders a minimal SVG placeholder that is
9-
# enhanced client-side by the AvatarFallbackElement web component to display
10-
# initials with a consistent color based on the user's unique_id.
8+
# When `src` is nil, this component renders an SVG with initials extracted from
9+
# the alt text. The AvatarFallbackElement web component then enhances it client-side
10+
# by applying a consistent background color based on the user's unique_id (using the
11+
# same hash function as OP Core for consistency).
1112
#
1213
# This component follows the "extension over mutation" pattern - it extends
1314
# Primer::Beta::Avatar without modifying its interface, ensuring compatibility
@@ -73,14 +74,33 @@ def fallback_avatar_classes
7374
)
7475
end
7576

77+
def extract_initials(name)
78+
return "" if name.blank?
79+
80+
chars = name.chars
81+
first = chars[0]&.upcase || ""
82+
83+
last_space = name.rindex(" ")
84+
if last_space && last_space < name.length - 1
85+
last = name[last_space + 1]&.upcase || ""
86+
"#{first}#{last}"
87+
else
88+
first
89+
end
90+
end
91+
7692
def render_fallback
93+
initials = extract_initials(@alt)
94+
7795
svg_content = content_tag(
7896
:svg,
7997
safe_join([
80-
tag.rect(width: "100%", height: "100%", fill: "currentColor"),
98+
# Use a neutral dark gray as default to minimize flicker in both light/dark modes
99+
# JS will replace with the hashed color (hsl(hue, 50%, 30%))
100+
tag.rect(width: "100%", height: "100%", fill: "hsl(0, 0%, 35%)"),
81101
content_tag(
82102
:text,
83-
nil,
103+
initials,
84104
x: "50%",
85105
y: "50%",
86106
"text-anchor": "middle",

0 commit comments

Comments
 (0)