diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_default/aria-snapshot.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_default/aria-snapshot.yml new file mode 100644 index 0000000000..d68c4ff2cf --- /dev/null +++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_default/aria-snapshot.yml @@ -0,0 +1 @@ +- img "OpenProject Admin" \ No newline at end of file diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_default/default.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_default/default.png new file mode 100644 index 0000000000..4f428a62c8 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_default/default.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_default/focused.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_default/focused.png new file mode 100644 index 0000000000..4f428a62c8 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_default/focused.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_single_name/aria-snapshot.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_single_name/aria-snapshot.yml new file mode 100644 index 0000000000..6523517f9a --- /dev/null +++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_single_name/aria-snapshot.yml @@ -0,0 +1 @@ +- img "John" \ No newline at end of file diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_single_name/default.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_single_name/default.png new file mode 100644 index 0000000000..be5a6ca3c9 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_single_name/default.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_single_name/focused.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_single_name/focused.png new file mode 100644 index 0000000000..be5a6ca3c9 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_single_name/focused.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_square/aria-snapshot.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_square/aria-snapshot.yml new file mode 100644 index 0000000000..00c1fccfa3 --- /dev/null +++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_square/aria-snapshot.yml @@ -0,0 +1 @@ +- img "OpenProject Org" \ No newline at end of file diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_square/default.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_square/default.png new file mode 100644 index 0000000000..ea6c9b65c4 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_square/default.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_square/focused.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_square/focused.png new file mode 100644 index 0000000000..ea6c9b65c4 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar/fallback_square/focused.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar_stack/with_fallback_avatars/aria-snapshot.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar_stack/with_fallback_avatars/aria-snapshot.yml new file mode 100644 index 0000000000..538bf943de --- /dev/null +++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar_stack/with_fallback_avatars/aria-snapshot.yml @@ -0,0 +1,3 @@ +- img "Alice Johnson" +- img "Bob Smith" +- img "Charlie Brown" \ No newline at end of file diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar_stack/with_fallback_avatars/default.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar_stack/with_fallback_avatars/default.png new file mode 100644 index 0000000000..d59e504f95 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar_stack/with_fallback_avatars/default.png differ diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar_stack/with_fallback_avatars/focused.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar_stack/with_fallback_avatars/focused.png new file mode 100644 index 0000000000..d59e504f95 Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/beta/avatar_stack/with_fallback_avatars/focused.png differ diff --git a/app/components/primer/beta/avatar.rb b/app/components/primer/beta/avatar.rb index f52dfc3d1b..1a28bff0ac 100644 --- a/app/components/primer/beta/avatar.rb +++ b/app/components/primer/beta/avatar.rb @@ -62,19 +62,33 @@ def initialize(src: nil, alt: nil, size: DEFAULT_SIZE, shape: DEFAULT_SHAPE, hre @system_arguments[:classes] = class_names( system_arguments[:classes], "avatar", - "avatar-small" => size < SMALL_THRESHOLD, - "circle" => shape == DEFAULT_SHAPE, + "avatar-small" => @size < SMALL_THRESHOLD, + "circle" => @shape == DEFAULT_SHAPE, "lh-0" => href # Addresses an overflow issue with linked avatars ) end def call - if @href - render(Primer::Beta::Link.new(href: @href, classes: @system_arguments[:classes])) do - render(Primer::BaseComponent.new(**@system_arguments.except(:classes))) { content } + avatar_element = + if @href + render(Primer::Beta::Link.new(href: @href, classes: @system_arguments[:classes])) do + render(Primer::BaseComponent.new(**@system_arguments.except(:classes))) { content } + end + else + render(Primer::BaseComponent.new(**@system_arguments)) { content } end + + # Wrap in Catalyst controller for client-side color correction (only for fallback SVGs) + if @system_arguments[:src]&.start_with?("data:image/svg+xml") + render(Primer::BaseComponent.new( + tag: :"avatar-fallback", + data: { + unique_id: @unique_id, + alt_text: @alt + } + )) { avatar_element } else - render(Primer::BaseComponent.new(**@system_arguments)) { content } + avatar_element end end @@ -129,6 +143,15 @@ def generate_avatar_color "hsl(#{value_hash(text)}, 50%, 30%)" end + # Mimics OP Core's string hash function to ensure consistent color generation + # + # Note: Due to differences in integer overflow handling between JavaScript and Ruby, + # the generated hash values differ, hence the color correction in the TS component. + # + # @see https://github.com/opf/openproject/blob/1b6eb3f9e45c3bdb05ce49d2cbe92995b87b4df5/frontend/src/app/shared/components/colors/colors.service.ts#L19-L26 + # + # @param value [String] The input string + # @return [Integer] A hash value between 0 and 359 def value_hash(value) return 0 if value.blank? diff --git a/app/components/primer/beta/avatar_fallback.ts b/app/components/primer/beta/avatar_fallback.ts new file mode 100644 index 0000000000..694e6a6bdf --- /dev/null +++ b/app/components/primer/beta/avatar_fallback.ts @@ -0,0 +1,59 @@ +import { attr, controller } from '@github/catalyst' + +@controller +export class AvatarFallbackElement extends HTMLElement { + @attr uniqueId = '' + @attr altText = '' + + connectedCallback() { + // Only update color if we have the necessary data and an SVG fallback + if (!this.uniqueId || !this.altText) return + + const img = this.querySelector('img[src^="data:image/svg+xml"]') + if (!img) return + + // Calculate correct color using OP Core hash algorithm + const text = `${this.uniqueId}${this.altText}` + const hue = this.valueHash(text) + const color = `hsl(${hue}, 50%, 30%)` + + // Update SVG with correct color + this.updateSvgColor(img as HTMLImageElement, color) + } + + /* + * Mimics OP Core's string hash function to ensure consistent color generation + * @see https://github.com/opf/openproject/blob/1b6eb3f9e45c3bdb05ce49d2cbe92995b87b4df5/frontend/src/app/shared/components/colors/colors.service.ts#L19-L26 + */ + private valueHash(value: string): number { + let hash = 0 + for (let i = 0; i < value.length; i++) { + hash = value.charCodeAt(i) + ((hash << 5) - hash) + } + return hash % 360 + } + + private updateSvgColor(img: HTMLImageElement, color: string) { + // Decode current SVG + const dataUri = img.src + const base64 = dataUri.replace('data:image/svg+xml;base64,', '') + const svg = atob(base64) + + // Replace fill color in rect element + const updatedSvg = svg.replace(/fill="hsl\([^"]+\)"/, `fill="${color}"`) + + // Encode and update + img.src = `data:image/svg+xml;base64,${btoa(updatedSvg)}` + } +} + +declare global { + interface Window { + AvatarFallbackElement: typeof AvatarFallbackElement + } +} + +if (!window.customElements.get('avatar-fallback')) { + window.AvatarFallbackElement = AvatarFallbackElement + window.customElements.define('avatar-fallback', AvatarFallbackElement) +} diff --git a/app/components/primer/primer.ts b/app/components/primer/primer.ts index d22e455704..661021dd27 100644 --- a/app/components/primer/primer.ts +++ b/app/components/primer/primer.ts @@ -17,6 +17,7 @@ import './alpha/toggle_switch' import './alpha/tool_tip' import './alpha/x_banner' import './beta/auto_complete/auto_complete' +import './beta/avatar_fallback' import './beta/clipboard_copy' import './beta/relative_time' import './alpha/tab_container' diff --git a/previews/primer/beta/avatar_preview.rb b/previews/primer/beta/avatar_preview.rb index da7468cf8a..749a1d6431 100644 --- a/previews/primer/beta/avatar_preview.rb +++ b/previews/primer/beta/avatar_preview.rb @@ -99,7 +99,7 @@ def shape_square # @label Fallback default # @snapshot def fallback_default - render(Primer::Beta::Avatar.new(alt: "OpenProject Admin", unique_id: 1)) + render(Primer::Beta::Avatar.new(alt: "OpenProject Admin", unique_id: 4)) end # @label Fallback single name diff --git a/test/components/beta/avatar_test.rb b/test/components/beta/avatar_test.rb index 49abdc11ce..c81ff6f7a5 100644 --- a/test/components/beta/avatar_test.rb +++ b/test/components/beta/avatar_test.rb @@ -251,6 +251,23 @@ def test_raises_when_both_src_and_alt_are_blank assert_includes(error.message, "`src` or `alt` is required") end + def test_fallback_wrapped_in_catalyst_controller + render_inline(Primer::Beta::Avatar.new(src: nil, alt: "Test User", unique_id: 123)) + + # Should be wrapped in avatar-fallback element with data attributes + assert_selector("avatar-fallback[data-unique-id='123'][data-alt-text='Test User']") do + assert_selector("img.avatar[src^='data:image/svg+xml']") + end + end + + def test_real_image_not_wrapped_in_catalyst_controller + render_inline(Primer::Beta::Avatar.new(src: "https://example.com/avatar.png", alt: "Test")) + + # Real images should NOT be wrapped + refute_selector("avatar-fallback") + assert_selector("img.avatar[src='https://example.com/avatar.png']") + end + def test_status assert_component_state(Primer::Beta::Avatar, :beta) end