Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9f3c2aa
Enhance `Primer::Beta::Avatar` to render initials in a styled SVG whe…
akabiru Dec 3, 2025
fa44e92
Mimic Op core JS based color hash function for consistency
akabiru Dec 4, 2025
f5a3c63
Refactor avatar fallback to OpenProject-specific component
akabiru Dec 15, 2025
201e2a1
Merge branch 'main' into bug/69230-fix-avatars-with-initials
akabiru Dec 15, 2025
493e4b5
Generating static files
openprojectci Dec 15, 2025
d883408
Cleanup refactor work
akabiru Dec 15, 2025
34561d8
Reduce avatar fallback flicker by rendering initials server-side
akabiru Dec 15, 2025
cfafc6e
Generating static files
openprojectci Dec 15, 2025
0489e2e
Simplify inheritance structure to reuse super class constructor more …
akabiru Dec 18, 2025
b94f630
Manage layout when avatars are wrapped in `<avatar-fallback>`
akabiru Dec 18, 2025
9c4c29d
Generating static files
openprojectci Dec 18, 2025
0005ea8
Address styelint and prettier errors
akabiru Dec 18, 2025
9dac8d5
Cover else branch in extract_initials when name has no spaces
akabiru Dec 18, 2025
0a1ccf6
Levarage `Primer::ConditionalWrapper` to simplify call method
akabiru Dec 18, 2025
0eb3baf
Set correct status on openproject components
akabiru Dec 23, 2025
d3e722f
Handle leading or trailing whitespaces in names
akabiru Jan 5, 2026
7bcca2f
Handle potential errors from `atob()` or `btoa()`
akabiru Jan 5, 2026
9a5608c
Use defined type instead of cast
akabiru Jan 5, 2026
3dced39
Generating static files
openprojectci Jan 5, 2026
4682247
Switch back to minor as we're on pre-1.0
akabiru Jan 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/heavy-donuts-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@openproject/primer-view-components': minor
---

Add `Primer::OpenProject::AvatarWithFallback` component with client-side fallback rendering. When no image src is provided, renders an embedded SVG with user initials and consistent colors generated client-side by the AvatarFallbackElement web component. Extends `Primer::Beta::Avatar` without modifying upstream behavior.
49 changes: 49 additions & 0 deletions app/components/primer/open_project/avatar_fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {attr, controller} from '@github/catalyst'

@controller
export class AvatarFallbackElement extends HTMLElement {
@attr uniqueId = ''
@attr altText = ''

connectedCallback() {
// If either uniqueId or altText is missing, skip color customization so the SVG
// keeps its default gray fill defined in the source and no color override is applied.
if (!this.uniqueId || !this.altText) return

const img = this.querySelector<HTMLImageElement>('img[src^="data:image/svg+xml"]')
if (!img) return

// Generate consistent color based on uniqueId and altText (hash must match OP Core)
const text = `${this.uniqueId}${this.altText}`
const hue = this.valueHash(text)
const color = `hsl(${hue}, 50%, 30%)`

this.updateSvgColor(img, 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) {
const dataUri = img.src
const base64 = dataUri.replace('data:image/svg+xml;base64,', '')

try {
const svg = atob(base64)
const updatedSvg = svg.replace(/fill="hsl\([^"]+\)"/, `fill="${color}"`)
img.src = `data:image/svg+xml;base64,${btoa(updatedSvg)}`
} catch {
// If the SVG data is malformed or not valid base64, skip updating the color
// to avoid breaking the component.
}
}
}
40 changes: 40 additions & 0 deletions app/components/primer/open_project/avatar_stack.pcss
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/* stylelint-disable selector-max-type, selector-max-specificity */
/* Type selectors are required for the avatar-fallback custom element wrapper.
Specificity overrides are needed to properly style nested avatar stacking. */

/* OpenProject AvatarStack - styles for avatar-fallback wrapper elements */

.AvatarStack-body {
/* Make avatar-fallback invisible to layout - inner img acts as direct child */
& avatar-fallback {
display: contents;
}

/*
* Z-index stacking for avatars inside avatar-fallback wrappers.
* The base CSS uses .avatar:first-child/:last-child but those selectors
* don't match when .avatar is inside avatar-fallback (not a direct child).
*/
& avatar-fallback:first-child .avatar {
z-index: 3;
}

& avatar-fallback:nth-child(2) .avatar {
z-index: 2;
}

/* Hide 4th+ wrapped avatars */
& avatar-fallback:nth-child(n + 4) {
display: none;
opacity: 0;
}

/* Show all on hover/focus */
&:hover:not([data-disable-expand]),
&:focus-within:not([data-disable-expand]) {
& avatar-fallback:nth-child(n + 4) {
display: contents;
opacity: 1;
}
}
}
23 changes: 23 additions & 0 deletions app/components/primer/open_project/avatar_stack.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

module Primer
module OpenProject
# OpenProject-specific AvatarStack that extends Primer::Beta::AvatarStack
# to support avatar fallbacks with initials.
#
# Uses a different slot name (avatar_with_fallbacks) to avoid conflicts with the parent's avatars slot.
class AvatarStack < Primer::Beta::AvatarStack
status :open_project

# Required list of stacked avatars with fallback support.
#
# @param kwargs [Hash] The same arguments as <%= link_to_component(Primer::OpenProject::AvatarWithFallback) %>.
renders_many :avatar_with_fallbacks, "Primer::OpenProject::AvatarWithFallback"

# Alias avatar_with_fallbacks as avatars for use in the template
def avatars
avatar_with_fallbacks
end
end
end
end
114 changes: 114 additions & 0 deletions app/components/primer/open_project/avatar_with_fallback.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# frozen_string_literal: true

module Primer
module OpenProject
# OpenProject-specific Avatar component that extends Primer::Beta::Avatar
# to support fallback rendering with initials when no image source is provided.
#
# When `src` is nil, this component renders an SVG with initials extracted from
# the alt text. The AvatarFallbackElement web component then enhances it client-side
# by applying a consistent background color based on the user's unique_id (using the
# same hash function as OP Core for consistency).
#
# This component follows the "extension over mutation" pattern - it extends
# Primer::Beta::Avatar without modifying its interface, ensuring compatibility
# with upstream changes.
class AvatarWithFallback < Primer::Beta::Avatar
status :open_project

# @see
# - https://primer.style/foundations/typography/
# - https://github.com/primer/css/blob/main/src/support/variables/typography.scss
FONT_STACK = "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'"

# @param src [String] The source url of the avatar image. When nil, renders a fallback with initials.
# @param alt [String] Alt text for the avatar. Used for accessibility and to generate initials when src is nil.
# @param size [Integer] <%= one_of(Primer::Beta::Avatar::SIZE_OPTIONS) %>
# @param shape [Symbol] Shape of the avatar. <%= one_of(Primer::Beta::Avatar::SHAPE_OPTIONS) %>
# @param href [String] The URL to link to. If used, component will be wrapped by an `<a>` tag.
# @param unique_id [String, Integer] Unique identifier for generating consistent avatar colors across renders.
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
def initialize(src: nil, alt: nil, size: DEFAULT_SIZE, shape: DEFAULT_SHAPE, href: nil, unique_id: nil, **system_arguments)
require_src_or_alt_arguments(src, alt)

@unique_id = unique_id
@use_fallback = src.blank?
final_src = @use_fallback ? generate_fallback_svg(alt, size) : src

super(src: final_src, alt: alt, size: size, shape: shape, href: href, **system_arguments)
end

def call
render(
Primer::ConditionalWrapper.new(
condition: @use_fallback,
tag: :"avatar-fallback",
data: {
unique_id: @unique_id,
alt_text: @system_arguments[:alt]
}
)
) { super }
end

private

def require_src_or_alt_arguments(src, alt)
return if src.present? || alt.present?

raise ArgumentError, "`src` or `alt` is required"
end

def generate_fallback_svg(alt, size)
svg_content = content_tag(
:svg,
safe_join([
# Use a neutral dark gray as default to minimize flicker in both light/dark modes
# JS will replace with the hashed color (hsl(hue, 50%, 30%))
tag.rect(width: "100%", height: "100%", fill: "hsl(0, 0%, 35%)"),
content_tag(
:text,
extract_initials(alt),
x: "50%",
y: "50%",
"text-anchor": "middle",
"dominant-baseline": "central",
fill: "white",
"font-size": fallback_font_size(size),
"font-weight": "600",
"font-family": FONT_STACK,
style: "user-select: none; text-transform: uppercase;"
)
]),
xmlns: "http://www.w3.org/2000/svg",
width: size,
height: size,
viewBox: "0 0 #{size} #{size}",
)

"data:image/svg+xml;base64,#{Base64.strict_encode64(svg_content)}"
end

def extract_initials(name)
name = name.to_s.strip
return "" if name.empty?

chars = name.chars
first = chars[0]&.upcase || ""

last_space = name.rindex(" ")
if last_space && last_space < name.length - 1
last = name[last_space + 1]&.upcase || ""
"#{first}#{last}"
else
first
end
end

def fallback_font_size(size)
# Font size is 45% of avatar size for good readability, with a minimum of 8px
[(size * 0.45).round, 8].max
end
end
end
end
1 change: 1 addition & 0 deletions app/components/primer/primer.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
@import "./alpha/action_bar.pcss";

/* OP specifics */
@import "./open_project/avatar_stack.pcss";
@import "./open_project/page_header.pcss";
@import "./open_project/drag_handle.pcss";
@import "./open_project/border_grid.pcss";
Expand Down
1 change: 1 addition & 0 deletions app/components/primer/primer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import './alpha/tree_view/tree_view'
import './alpha/tree_view/tree_view_icon_pair_element'
import './alpha/tree_view/tree_view_sub_tree_node_element'
import './alpha/tree_view/tree_view_include_fragment_element'
import './open_project/avatar_fallback'
import './open_project/page_header_element'
import './open_project/zen_mode_button'
import './open_project/sub_header_element'
Expand Down
70 changes: 70 additions & 0 deletions previews/primer/open_project/avatar_stack_preview.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# frozen_string_literal: true

module Primer
module OpenProject
# @label OpenProject AvatarStack
class AvatarStackPreview < ViewComponent::Preview
# @label Playground
#
# @param number_of_avatars [Integer] number
# @param with_fallbacks toggle
# @param align select [["Left", left], ["Right", right]]
def playground(number_of_avatars: 3, with_fallbacks: true, align: :left)
render(Primer::OpenProject::AvatarStack.new(align: align)) do |component|
Array.new(number_of_avatars&.to_i || 1) do |i|
if with_fallbacks
component.with_avatar_with_fallback(src: nil, alt: "User #{i + 1}", unique_id: i + 1)
else
component.with_avatar_with_fallback(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser")
end
end
end
end

# @label Default
# @snapshot
def default
render(Primer::OpenProject::AvatarStack.new) do |component|
component.with_avatar_with_fallback(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser")
end
end

# @label With fallback avatars
# @snapshot
def with_fallback_avatars
render(Primer::OpenProject::AvatarStack.new) do |component|
component.with_avatar_with_fallback(src: nil, alt: "Alice Johnson", unique_id: 1)
component.with_avatar_with_fallback(src: nil, alt: "Bob Smith", unique_id: 2)
component.with_avatar_with_fallback(src: nil, alt: "Charlie Brown", unique_id: 3)
end
end

# @label Mixed (image and fallback)
def mixed_avatars
render(Primer::OpenProject::AvatarStack.new) do |component|
component.with_avatar_with_fallback(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser")
component.with_avatar_with_fallback(src: nil, alt: "Alice Johnson", unique_id: 10)
component.with_avatar_with_fallback(src: nil, alt: "Bob Smith", unique_id: 20)
end
end

# @label Align right with fallbacks
def align_right_with_fallbacks
render(Primer::OpenProject::AvatarStack.new(align: :right)) do |component|
component.with_avatar_with_fallback(src: nil, alt: "Alice Johnson", unique_id: 1)
component.with_avatar_with_fallback(src: nil, alt: "Bob Smith", unique_id: 2)
component.with_avatar_with_fallback(src: nil, alt: "Charlie Brown", unique_id: 3)
end
end

# @label With tooltip and fallbacks
def with_tooltip_and_fallbacks
render(Primer::OpenProject::AvatarStack.new(tooltipped: true, body_arguments: { label: "Team members" })) do |component|
component.with_avatar_with_fallback(src: nil, alt: "Alice Johnson", unique_id: 1)
component.with_avatar_with_fallback(src: nil, alt: "Bob Smith", unique_id: 2)
component.with_avatar_with_fallback(src: nil, alt: "Charlie Brown", unique_id: 3)
end
end
end
end
end
71 changes: 71 additions & 0 deletions previews/primer/open_project/avatar_with_fallback_preview.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# frozen_string_literal: true

module Primer
module OpenProject
# @label AvatarWithFallback
class AvatarWithFallbackPreview < ViewComponent::Preview
# @label Playground
#
# @param size [Integer] select [16, 20, 24, 32, 40, 48, 64, 80]
# @param shape [Symbol] select [circle, square]
# @param href [String] text
# @param with_src [Boolean] toggle
def playground(size: 24, shape: :circle, href: nil, with_src: false)
if with_src
render(Primer::OpenProject::AvatarWithFallback.new(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser", size: size, shape: shape, href: href))
else
render(Primer::OpenProject::AvatarWithFallback.new(alt: "OpenProject Admin", unique_id: 4, size: size, shape: shape, href: href))
end
end

# @label Default
# @snapshot
def default
render(Primer::OpenProject::AvatarWithFallback.new(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser"))
end

# @label With image src
# @snapshot
def with_image
render(Primer::OpenProject::AvatarWithFallback.new(src: Primer::ExampleImage::BASE64_SRC, alt: "@kittenuser"))
end

# @!group Fallback (Initials)
#
# @label Fallback with initials
# @snapshot
def fallback_default
render(Primer::OpenProject::AvatarWithFallback.new(alt: "OpenProject Admin", unique_id: 4))
end

# @label Fallback single name
# @snapshot
def fallback_single_name
render(Primer::OpenProject::AvatarWithFallback.new(alt: "John", unique_id: 2))
end

# @label Fallback multiple users
def fallback_multiple
render_with_template(locals: {})
end

# @label Fallback sizes
def fallback_sizes
render_with_template(locals: {})
end

# @label Fallback square shape
# @snapshot
def fallback_square
render(Primer::OpenProject::AvatarWithFallback.new(alt: "OpenProject Org", unique_id: 100, shape: :square))
end

# @label Fallback as link
def fallback_as_link
render(Primer::OpenProject::AvatarWithFallback.new(alt: "Jane Doe", unique_id: 3, href: "#"))
end
#
# @!endgroup
end
end
end
Loading
Loading