Skip to content

Commit f65158c

Browse files
committed
Mimic Op core JS based color hash function for consistency
The hash function is not consistently portable between JS/Ruby due to different "integer overflow handling (!?)". JS based coloring establishes consistency while we have both primer legacy (angular) component rendering avatar fallback Mimic Op core JS based color hash function for consistency The hash function is not consistently portable between JS/Ruby due to different "integer overflow handling (!?)". JS based coloring establishes consistency while we have both primer legacy (angular) component rendering avatar fallback
1 parent 02744e3 commit f65158c

File tree

5 files changed

+102
-7
lines changed

5 files changed

+102
-7
lines changed

app/components/primer/beta/avatar.rb

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,19 +62,34 @@ def initialize(src: nil, alt: nil, size: DEFAULT_SIZE, shape: DEFAULT_SHAPE, hre
6262
@system_arguments[:classes] = class_names(
6363
system_arguments[:classes],
6464
"avatar",
65-
"avatar-small" => size < SMALL_THRESHOLD,
66-
"circle" => shape == DEFAULT_SHAPE,
65+
"avatar-small" => @size < SMALL_THRESHOLD,
66+
"circle" => @shape == DEFAULT_SHAPE,
6767
"lh-0" => href # Addresses an overflow issue with linked avatars
6868
)
6969
end
7070

7171
def call
72-
if @href
73-
render(Primer::Beta::Link.new(href: @href, classes: @system_arguments[:classes])) do
74-
render(Primer::BaseComponent.new(**@system_arguments.except(:classes))) { content }
72+
avatar_element =
73+
if @href
74+
render(Primer::Beta::Link.new(href: @href, classes: @system_arguments[:classes])) do
75+
render(Primer::BaseComponent.new(**@system_arguments.except(:classes))) { content }
76+
end
77+
else
78+
render(Primer::BaseComponent.new(**@system_arguments)) { content }
7579
end
80+
81+
# Wrap the avatar in an <avatar-fallback> custom element if the source
82+
# is an SVG data URI (for client-side color correction)
83+
if @system_arguments[:src]&.start_with?("data:image/svg+xml")
84+
render(Primer::BaseComponent.new(
85+
tag: :"avatar-fallback",
86+
data: {
87+
unique_id: @unique_id,
88+
alt_text: @alt
89+
}
90+
)) { avatar_element }
7691
else
77-
render(Primer::BaseComponent.new(**@system_arguments)) { content }
92+
avatar_element
7893
end
7994
end
8095

@@ -129,6 +144,15 @@ def generate_avatar_color
129144
"hsl(#{value_hash(text)}, 50%, 30%)"
130145
end
131146

147+
# Mimics OP Core's string hash function to ensure consistent color generation
148+
#
149+
# Note: Due to differences in integer overflow handling between JavaScript and Ruby,
150+
# the generated hash values differ, hence the color correction in the TS component.
151+
#
152+
# @see AvatarFallbackElement#valueHash in app/components/primer/beta/avatar_fallback.ts
153+
#
154+
# @param value [String] The input string
155+
# @return [Integer] A hash value between 0 and 359
132156
def value_hash(value)
133157
return 0 if value.blank?
134158

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {attr, controller} from '@github/catalyst'
2+
3+
@controller
4+
export class AvatarFallbackElement extends HTMLElement {
5+
@attr uniqueId = ''
6+
@attr altText = ''
7+
8+
connectedCallback() {
9+
if (!this.uniqueId || !this.altText) return
10+
11+
const img = this.querySelector('img[src^="data:image/svg+xml"]')
12+
if (!img) return
13+
14+
const text = `${this.uniqueId}${this.altText}`
15+
const hue = this.valueHash(text)
16+
const color = `hsl(${hue}, 50%, 30%)`
17+
18+
this.updateSvgColor(img as HTMLImageElement, color)
19+
}
20+
21+
/*
22+
* Mimics OP Core's string hash function to ensure consistent color generation
23+
* @see https://github.com/opf/openproject/blob/1b6eb3f9e45c3bdb05ce49d2cbe92995b87b4df5/frontend/src/app/shared/components/colors/colors.service.ts#L19-L26
24+
*/
25+
private valueHash(value: string): number {
26+
let hash = 0
27+
for (let i = 0; i < value.length; i++) {
28+
hash = value.charCodeAt(i) + ((hash << 5) - hash)
29+
}
30+
return hash % 360
31+
}
32+
33+
private updateSvgColor(img: HTMLImageElement, color: string) {
34+
const dataUri = img.src
35+
const base64 = dataUri.replace('data:image/svg+xml;base64,', '')
36+
const svg = atob(base64)
37+
38+
const updatedSvg = svg.replace(/fill="hsl\([^"]+\)"/, `fill="${color}"`)
39+
40+
img.src = `data:image/svg+xml;base64,${btoa(updatedSvg)}`
41+
}
42+
}
43+
44+
declare global {
45+
interface Window {
46+
AvatarFallbackElement: typeof AvatarFallbackElement
47+
}
48+
}
49+
50+
if (!window.customElements.get('avatar-fallback')) {
51+
window.AvatarFallbackElement = AvatarFallbackElement
52+
window.customElements.define('avatar-fallback', AvatarFallbackElement)
53+
}

app/components/primer/primer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import './alpha/toggle_switch'
1717
import './alpha/tool_tip'
1818
import './alpha/x_banner'
1919
import './beta/auto_complete/auto_complete'
20+
import './beta/avatar_fallback'
2021
import './beta/clipboard_copy'
2122
import './beta/relative_time'
2223
import './alpha/tab_container'

previews/primer/beta/avatar_preview.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ def shape_square
9999
# @label Fallback default
100100
# @snapshot
101101
def fallback_default
102-
render(Primer::Beta::Avatar.new(alt: "OpenProject Admin", unique_id: 1))
102+
render(Primer::Beta::Avatar.new(alt: "OpenProject Admin", unique_id: 4))
103103
end
104104

105105
# @label Fallback single name

test/components/beta/avatar_test.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,23 @@ def test_raises_when_both_src_and_alt_are_blank
251251
assert_includes(error.message, "`src` or `alt` is required")
252252
end
253253

254+
def test_fallback_wrapped_in_catalyst_controller
255+
render_inline(Primer::Beta::Avatar.new(src: nil, alt: "Test User", unique_id: 123))
256+
257+
# Should be wrapped in avatar-fallback element with data attributes
258+
assert_selector("avatar-fallback[data-unique-id='123'][data-alt-text='Test User']") do
259+
assert_selector("img.avatar[src^='data:image/svg+xml']")
260+
end
261+
end
262+
263+
def test_real_image_not_wrapped_in_catalyst_controller
264+
render_inline(Primer::Beta::Avatar.new(src: "https://example.com/avatar.png", alt: "Test"))
265+
266+
# Real images should NOT be wrapped
267+
refute_selector("avatar-fallback")
268+
assert_selector("img.avatar[src='https://example.com/avatar.png']")
269+
end
270+
254271
def test_status
255272
assert_component_state(Primer::Beta::Avatar, :beta)
256273
end

0 commit comments

Comments
 (0)