From 1328098c452819921c48b01b1828d6f3371e1333 Mon Sep 17 00:00:00 2001 From: Arnaud Robin Date: Tue, 4 Aug 2026 15:47:42 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(frontend)=20keep=20Unicode=20initi?= =?UTF-8?q?als=20intact=20in=20avatar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some characters span multiple UTF-16 code units. Taking a naive first index for avatar initials can split them and show a broken glyph when the camera is off. Optically fix initials centering with a more complex approach. --- CHANGELOG.md | 1 + src/frontend/src/components/Avatar.tsx | 66 +++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79bc89eb..7ac4ffb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ and this project adheres to - 🐛(backend) serialize lazy title in summary payload - 💄(frontend) show pointer cursor on interactive switches - 🐛(frontend) fix icon centering in the Switch primitive +- 🐛(frontend) keep Unicode initials intact in avatar ## [1.24.0] - 2026-07-21 diff --git a/src/frontend/src/components/Avatar.tsx b/src/frontend/src/components/Avatar.tsx index 4a4feb60..36bf7257 100644 --- a/src/frontend/src/components/Avatar.tsx +++ b/src/frontend/src/components/Avatar.tsx @@ -1,5 +1,5 @@ import { css, cva, RecipeVariantProps } from '@/styled-system/css' -import React from 'react' +import React, { useLayoutEffect, useMemo } from 'react' const avatar = cva({ base: { @@ -28,13 +28,34 @@ const avatar = cva({ }, }) +// Instantiating a segmenter is expensive; create it once and reuse it. +const graphemeSegmenter = + typeof Intl !== 'undefined' && 'Segmenter' in Intl + ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + : undefined + +/** + * Returns the first user-perceived character. Some Unicode characters span + * multiple UTF-16 code units, so a naive index into the string can split them + * and yield a broken glyph. + */ +const getFirstGrapheme = (value: string): string => { + if (!value) return '' + if (graphemeSegmenter) { + const [first] = graphemeSegmenter.segment(value) + return first?.segment ?? '' + } + // Fallback: keeps single code points intact (including surrogate pairs). + return Array.from(value)[0] ?? '' +} + const getInitials = (name?: string): string => { if (!name) return '' const words = name.trim().split(/\s+/).filter(Boolean) if (words.length === 0) return '' - const first = words[0].charAt(0) - const second = words.length > 1 ? words[1].charAt(0) : '' - return (first + second).toUpperCase() + const first = getFirstGrapheme(words[0]) + const second = words.length > 1 ? getFirstGrapheme(words[1]) : '' + return (first + second).toLocaleUpperCase() } export type AvatarProps = React.HTMLAttributes & { @@ -44,7 +65,37 @@ export type AvatarProps = React.HTMLAttributes & { export const Avatar = React.memo( ({ name, bgColor, context, notification, style, ...props }: AvatarProps) => { - const initials = getInitials(name) + const initials = useMemo(() => getInitials(name), [name]) + const textRef = React.useRef(null) + const [offsetY, setOffsetY] = React.useState(0) + + // Optically center the initials: measure the ink bounding box of the + // rendered glyphs and shift them so the box's center sits at the middle + // of the viewBox. Works for any font, weight or glyph shape, unlike a + // hand-tuned dy offset. getBBox() is in local (pre-transform) + // coordinates, so applying the translation never changes the measure. + useLayoutEffect(() => { + const text = textRef.current + if (!text) return + + const center = () => { + const box = text.getBBox() + // A hidden element measures as an empty box; keep the default then. + if (box.height === 0) return + setOffsetY(50 - (box.y + box.height / 2)) + } + + center() + // Glyph metrics can change once webfonts finish loading. + let cancelled = false + document.fonts?.ready.then(() => { + if (!cancelled) center() + }) + return () => { + cancelled = true + } + }, [initials]) + return (
- {initials.toUpperCase()} + {initials}